Mock API
AppFuse Web 使用 MSW (Mock Service Worker) 在開發環境模擬後端 API,提供完整的多租戶、認證和 CRUD 功能。
架構概覽
快速開始
啟動 Mock API
Mock API 在開發環境自動啟動:
// src/main.tsx
if (import.meta.env.DEV) {
const { worker } = await import('./mocks/browser');
await worker.start({
onUnhandledRequest: 'bypass',
});
}
測試帳號
| 帳號 | 密碼 | 角色 | 用途 |
|---|---|---|---|
| admin | Password123! | TENANT_ADMIN | 租戶管理員 |
| manager | Password123! | MANAGER | 店長 |
| sales | Password123! | SALES | 銷售人員 |
| florist | Password123! | FLORIST | 花藝師 |
| delivery | Password123! | DELIVERY | 配送員 |
目錄結構
src/mocks/
├── browser.ts # MSW 初始化
├── 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
└── ...
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 取得單筆
PATCH /api/v1/products/:id 更新商品
DELETE /api/v1/products/:id 刪除商品
訂單 API
GET /api/v1/orders 取得列表
POST /api/v1/orders 建立訂單
GET /api/v1/orders/:id 取得單筆
PATCH /api/v1/orders/:id 更新訂單
PATCH /api/v1/orders/:id/status 變更狀態
GET /api/v1/orders/:id/history 取得狀態歷史
客戶 API
GET /api/v1/customers 取得列表
POST /api/v1/customers 建立客戶
GET /api/v1/customers/:id 取得單筆
PATCH /api/v1/customers/:id 更新客戶
GET /api/v1/customers/:id/stats 取得統計
Handler 實作
基本結構
// src/mocks/handlers/products.ts
import { http, HttpResponse } from 'msw';
import { checkAuth } from '../middleware/auth-middleware';
import { tenantQuery, db } from '../data/db';
const API_BASE = '/api/v1';
export const productHandlers = [
// GET /api/v1/products
http.get(`${API_BASE}/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}/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 });
}),
];
認證中介層
// src/mocks/middleware/auth-middleware.ts
export interface AuthCheckResult {
success: boolean;
userId?: string;
tenantId?: string;
role?: string;
error?: Response;
}
export function checkAuth(request: Request): AuthCheckResult {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return {
success: false,
error: createErrorResponse('Unauthorized', 401),
};
}
const token = authHeader.slice(7);
const payload = verifyToken(token);
if (!payload) {
return {
success: false,
error: createErrorResponse('Invalid token', 401),
};
}
return {
success: true,
userId: payload.sub,
tenantId: payload.tenantId,
role: payload.role,
};
}
租戶隔離
// 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 function createErrorResponse(
message: string,
status: number,
details?: { type?: string; title?: string }
): Response {
return HttpResponse.json(
{
type: details?.type ? `urn:appfuse:error:${details.type}` : undefined,
title: details?.title || 'Error',
status,
detail: message,
},
{ status }
);
}
常見錯誤
| HTTP 狀態碼 | 錯誤類型 | 說明 |
|---|---|---|
| 400 | validation-error | 欄位驗證失敗 |
| 401 | bad-credentials | 帳號密碼錯誤 |
| 401 | unauthorized | Token 無效或過期 |
| 403 | permission-denied | 無權限 |
| 404 | not-found | 資源不存在 |
| 423 | account-locked | 帳號被鎖定 |
種子資料
自動生成
// src/mocks/data/seeds.ts
import { faker } from '@faker-js/faker/locale/zh_TW';
export function seedDatabase(): void {
// 建立租戶
const tenant = createTenant('default-tenant', '玫瑰花園花店');
// 為租戶建立資料
seedTenantData(tenant.id);
}
function seedTenantData(tenantId: string): void {
const products = createProducts(tenantId, 20);
const customers = createCustomers(tenantId, 15);
const orders = createOrders(tenantId, customers, products, 237);
db.products.push(...products);
db.customers.push(...customers);
db.orders.push(...orders);
}
預設資料量
| 實體 | 數量 | 說明 |
|---|---|---|
| 租戶 | 2 | 玫瑰花園、蘭花軒 |
| 用戶 | 8 | 各角色測試帳號 |
| 商品 | 20 | 含 7 種分類 |
| 客戶 | 15 | 個人 + 企業 |
| 訂單 | 237 | 各種狀態 |
分頁格式
使用 HTTP Headers 傳遞分頁資訊:
return HttpResponse.json(paginatedData, {
status: 200,
headers: {
'X-Total-Count': String(totalItems),
'X-Page': String(page),
'X-Per-Page': String(limit),
},
});
新增 Handler
步驟
- 定義型別 (
types.ts) - 建立 Handler 檔案 (
handlers/xxx.ts) - 註冊到 index (
handlers/index.ts)
檢查清單
- 使用
checkAuth()檢查認證 - 使用
tenantQuery確保租戶隔離 - 使用
createErrorResponse()處理錯誤 - 遵循 HTTP 狀態碼規範(201 建立、204 刪除)
- 記錄日誌
logger.info()
最佳實踐
- 所有 API 需認證 - 除登入外,一律使用
checkAuth() - 租戶隔離 - 永遠使用
tenantQuery.*()方法 - RFC 7807 錯誤 - 使用標準格式回傳錯誤
- HTTP Headers 分頁 - 不要用包裝物件
- 日誌記錄 - 所有操作都要記錄