跳至主要内容

Mock API

AppFuse Web 使用 MSW (Mock Service Worker) 在開發環境模擬後端 API,提供完整的多租戶、認證和 CRUD 功能。

架構概覽

快速開始

啟動 Mock API

MSW 由 App.tsx 在初始化階段根據環境配置決定是否啟動:

// src/App.tsx(步驟 2: 配置基礎服務)
if (environOpts.app.msw?.enabled) {
try {
const { worker } = await import('./mocks/browser')
await worker.start({
onUnhandledRequest: 'bypass',
serviceWorker: {
url: './mockServiceWorker.js',
},
})
console.log('[MSW] Mock Service Worker started')
} catch (error) {
console.warn('[MSW] Failed to start Mock Service Worker:', error)
}
}
  • 開發環境(Vite dev server):預設啟用
  • 生產環境(app-office-host):根據 app.msw.enabled 配置

browser.ts 負責建立 worker 實例與初始化種子資料,但不負責啟動:

// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
import { seedDatabase } from './data/seeds'

seedDatabase()
export const worker = setupWorker(...handlers)

測試帳號

共 10 個測試帳號,密碼統一為 Password123!

帳號角色說明
superadminSUPER_ADMIN系統超級管理員(無租戶)
adminTENANT_ADMIN租戶管理員
managerMANAGER店長
salesSALES銷售人員
floristFLORIST花藝師
deliveryDELIVERY配送員
accountantACCOUNTANT會計
userTENANT_USER基本租戶使用者
multi_role_userSALES + FLORIST多角色測試帳號
disabled_userTENANT_USER停用帳號(登入會失敗)

目錄結構

src/mocks/
├── browser.ts # MSW worker 實例(由 App.tsx 決定是否啟動)
├── types.ts # API 型別定義
├── data/
│ ├── db.ts # In-Memory 資料庫
│ ├── seeds.ts # 種子資料生成
│ └── test-accounts.ts # 測試帳號
├── utils/
│ ├── jwt.ts # JWT 工具
│ └── error-helpers.ts # 錯誤回應工具
├── middleware/
│ └── auth-middleware.ts # 認證中介層
└── handlers/
├── index.ts # Handler 彙整
├── auth.ts # 認證 API
├── products.ts # 商品 API
├── orders.ts # 訂單 API
├── customers.ts # 客戶 API
├── dashboard.ts # 儀表板 API
├── reference-data.ts # 參考資料 API
└── files.ts # 檔案上傳 API

API 端點

認證 API

POST /api/v1/auth/login 登入(支援帳號鎖定)
POST /api/v1/auth/refresh 刷新 Token
GET /api/v1/auth/me 取得當前用戶
POST /api/v1/auth/logout 登出

商品 API

GET /api/v1/products 取得列表(搜尋、篩選、分頁)
POST /api/v1/products 建立商品
GET /api/v1/products/:id 取得單筆
PUT /api/v1/products/:id 完整更新商品
PATCH /api/v1/products/:id 部分更新商品
PATCH /api/v1/products/:id/status 變更上架狀態
DELETE /api/v1/products/:id 刪除商品
POST /api/v1/products/upload-image 上傳商品圖片
GET /api/v1/products/:productId/images/:index 取得商品圖片

訂單 API

GET /api/v1/orders 取得列表
POST /api/v1/orders 建立訂單
GET /api/v1/orders/:id 取得單筆
PUT /api/v1/orders/:id 完整更新訂單
PATCH /api/v1/orders/:id 部分更新訂單
DELETE /api/v1/orders/:id 刪除訂單
PATCH /api/v1/orders/:id/status 變更狀態
GET /api/v1/orders/:id/status-history 取得狀態歷史
GET /api/v1/orders/:orderId/production-photos/:index 取得製作照片
GET /api/v1/orders/:orderId/delivery-photos/:index 取得配送照片

客戶 API

GET /api/v1/customers 取得列表
POST /api/v1/customers 建立客戶
GET /api/v1/customers/check-duplicate 重複檢查
GET /api/v1/customers/:id 取得單筆
PATCH /api/v1/customers/:id 更新客戶
PATCH /api/v1/customers/:id/status 變更客戶狀態
GET /api/v1/customers/:id/orders 取得客戶訂單
GET /api/v1/customers/:id/stats 取得統計
GET /api/v1/customers/:id/notes 取得客戶備註
POST /api/v1/customers/:id/notes 新增客戶備註

儀表板 API

GET /api/v1/dashboard/stats 取得統計數據
GET /api/v1/dashboard/recent-orders 取得最近訂單
GET /api/v1/dashboard/inventory-alerts 取得庫存警示
GET /api/v1/dashboard/notifications 取得通知

參考資料 API

GET /api/v1/reference-data/order-statuses 取得訂單狀態列表
GET /api/v1/reference-data/payment-methods 取得付款方式列表

檔案 API

POST /api/v1/staging/files/prepare 預備上傳
PUT /api/v1/files/staging/:date/:uuid 上傳檔案
GET /api/v1/files/staging/:date/:uuid 取得檔案
DELETE /api/v1/files/staging/:date/:uuid 刪除檔案

Handler 實作

基本結構

// src/mocks/handlers/products.ts
import { http, HttpResponse } from 'msw';
import { checkAuth } from '@/mocks/middleware/auth-middleware';
import { db, tenantQuery } from '@/mocks/data/db';

const API_BASE = '*';

export const productHandlers = [
// GET /api/v1/products
http.get(`${API_BASE}/api/v1/products`, ({ request }) => {
// 1. 認證檢查
const authCheck = checkAuth(request);
if (!authCheck.success) return authCheck.error!;
const { tenantId } = authCheck;

// 2. 取得租戶資料(租戶隔離)
const products = tenantQuery.getByTenant(db.products, tenantId!);

// 3. 回傳響應
return HttpResponse.json(products, {
headers: {
'X-Total-Count': String(products.length),
},
});
}),

// POST /api/v1/products
http.post(`${API_BASE}/api/v1/products`, async ({ request }) => {
const authCheck = checkAuth(request);
if (!authCheck.success) return authCheck.error!;

const body = await request.json();

const product = {
id: generateId(),
tenantId: authCheck.tenantId!,
...body,
createdAt: new Date(),
};

tenantQuery.create(db.products, product, authCheck.tenantId!);

return HttpResponse.json(product, { status: 201 });
}),
];

:::info 萬用字元前綴 所有 handler 使用 API_BASE = '*' 作為 URL 前綴,使 MSW 能在不同 context-path(如 /app/api/v1/...)下正確匹配請求。 :::

認證中介層

// src/mocks/middleware/auth-middleware.ts
export interface AuthCheckResult {
success: boolean;
userId?: string;
tenantId?: string;
role?: RoleName;
error?: Response;
}

export function checkAuth(
request: Request,
requireAuth = true
): AuthCheckResult {
const authHeader = request.headers.get('Authorization');

if (!authHeader?.startsWith('Bearer ')) {
if (requireAuth) {
return {
success: false,
error: createErrorResponse('Unauthorized - Missing or invalid token', 401, {
type: 'unauthorized',
title: 'Unauthorized',
instance: new URL(request.url).pathname,
}),
};
}
return { success: true };
}

const payload = getPayloadFromRequest(authHeader);

if (!payload) {
return {
success: false,
error: createErrorResponse('Unauthorized - Invalid or expired token', 401, {
type: 'unauthorized',
title: 'Unauthorized',
instance: new URL(request.url).pathname,
}),
};
}

const user = db.users.find((u) => u.id === payload.sub);

if (!user) {
return {
success: false,
error: createErrorResponse('Unauthorized - User not found', 401, {
type: 'unauthorized',
title: 'Unauthorized',
instance: new URL(request.url).pathname,
}),
};
}

return {
success: true,
userId: user.id,
tenantId: user.tenantId,
role: user.role as RoleName,
};
}

租戶隔離

// src/mocks/data/db.ts
export const tenantQuery = {
// 取得租戶資料
getByTenant<T extends { tenantId: string }>(
collection: T[],
tenantId: string
): T[] {
return collection.filter((item) => item.tenantId === tenantId);
},

// 查找單筆(含租戶驗證)
findById<T extends { id: string; tenantId: string }>(
collection: T[],
id: string,
tenantId: string
): T | undefined {
return collection.find(
(item) => item.id === id && item.tenantId === tenantId
);
},

// 新增(驗證 tenantId)
create<T extends { tenantId: string }>(
collection: T[],
item: T,
tenantId: string
): T {
if (item.tenantId !== tenantId) {
throw new Error('Tenant ID mismatch');
}
collection.push(item);
return item;
},
};

錯誤處理

RFC 7807 格式

所有錯誤回應使用 RFC 7807 Problem Details 格式:

// src/mocks/utils/error-helpers.ts
export interface ErrorDetails {
type?: string; // 問題類型(會加上 urn:appfuse:error: 前綴)
title?: string; // 問題標題
instance?: string; // 發生問題的請求路徑
errorCode?: string; // 應用程式錯誤代碼
violations?: {
message: string;
format?: string;
props?: string[];
params?: Record<string, unknown>;
}[];
}

export function createErrorResponse(
message: string,
status: number,
details?: ErrorDetails
): Response {
const error = new ErrorResponse({
status,
detail: message,
type: details?.type ? `urn:appfuse:error:${details.type}` : undefined,
title: details?.title,
instance: details?.instance,
errorCode: details?.errorCode,
violations: details?.violations,
});
return errorResponse(error);
}

常見錯誤

HTTP 狀態碼錯誤類型說明
400validation-error欄位驗證失敗
401bad-credentials帳號密碼錯誤
401unauthorizedToken 無效或過期
403permission-denied無權限
404not-found資源不存在
423account-locked帳號被鎖定

種子資料

自動生成

種子資料在 browser.ts 匯入時自動執行:

// src/mocks/data/seeds.ts
import { faker } from '@faker-js/faker/locale/zh_TW';

export function seedDatabase(): void {
// 清空現有數據
// ...

// 創建租戶(使用固定 ID)
const tenant1 = createTenant('default-tenant', '玫瑰花園花店', 'rose-garden', '0912-345-678');
const tenant2 = createTenant('tenant-2', '蘭花軒', 'orchid-pavilion', '0987-654-321');

db.tenants.push(tenant1, tenant2);

// 為租戶1創建完整數據
seedTenantData(tenant1.id);

// 為租戶2創建少量數據
seedTenantData(tenant2.id, { minimal: true });
}

預設資料量

實體數量(租戶 1)數量(租戶 2)說明
租戶共 2 個:玫瑰花園、蘭花軒
用戶1010各角色測試帳號
商品205含 7 種分類
客戶153個人 + 企業
訂單2375各種狀態

分頁格式

使用 HTTP Headers 傳遞分頁資訊:

return HttpResponse.json(paginatedData, {
status: 200,
headers: {
'X-Total-Count': String(totalItems),
'X-Page': String(page),
'X-Per-Page': String(limit),
},
});

新增 Handler

步驟

  1. 定義型別 (types.ts)
  2. 建立 Handler 檔案 (handlers/xxx.ts)
  3. 註冊到 index (handlers/index.ts)

檢查清單

  • 使用 checkAuth() 檢查認證
  • 使用 tenantQuery 確保租戶隔離
  • 使用 createErrorResponse() 處理錯誤
  • 遵循 HTTP 狀態碼規範(201 建立、204 刪除)
  • 記錄日誌 logger.info()
  • URL 使用 ${API_BASE}/api/v1/... 格式(萬用字元前綴)

最佳實踐

  1. 所有 API 需認證 - 除登入外,一律使用 checkAuth()
  2. 租戶隔離 - 永遠使用 tenantQuery.*() 方法
  3. RFC 7807 錯誤 - 使用標準格式回傳錯誤
  4. HTTP Headers 分頁 - 不要用包裝物件
  5. 日誌記錄 - 所有操作都要記錄
  6. 萬用字元前綴 - Handler URL 使用 API_BASE = '*' 以支援不同 context-path

下一步