跳至主要内容

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',
});
}

測試帳號

帳號密碼角色用途
adminPassword123!TENANT_ADMIN租戶管理員
managerPassword123!MANAGER店長
salesPassword123!SALES銷售人員
floristPassword123!FLORIST花藝師
deliveryPassword123!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 狀態碼錯誤類型說明
400validation-error欄位驗證失敗
401bad-credentials帳號密碼錯誤
401unauthorizedToken 無效或過期
403permission-denied無權限
404not-found資源不存在
423account-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

步驟

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

檢查清單

  • 使用 checkAuth() 檢查認證
  • 使用 tenantQuery 確保租戶隔離
  • 使用 createErrorResponse() 處理錯誤
  • 遵循 HTTP 狀態碼規範(201 建立、204 刪除)
  • 記錄日誌 logger.info()

最佳實踐

  1. 所有 API 需認證 - 除登入外,一律使用 checkAuth()
  2. 租戶隔離 - 永遠使用 tenantQuery.*() 方法
  3. RFC 7807 錯誤 - 使用標準格式回傳錯誤
  4. HTTP Headers 分頁 - 不要用包裝物件
  5. 日誌記錄 - 所有操作都要記錄

下一步