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!:
| 帳號 | 角色 | 說明 |
|---|---|---|
| superadmin | SUPER_ADMIN | 系統超級管理員(無租戶) |
| admin | TENANT_ADMIN | 租戶管理員 |
| manager | MANAGER | 店長 |
| sales | SALES | 銷售人員 |
| florist | FLORIST | 花藝師 |
| delivery | DELIVERY | 配送員 |
| accountant | ACCOUNTANT | 會計 |
| user | TENANT_USER | 基本租戶使用者 |
| multi_role_user | SALES + FLORIST | 多角色測試帳號 |
| disabled_user | TENANT_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 狀態碼 | 錯誤類型 | 說明 |
|---|---|---|
| 400 | validation-error | 欄位驗證失敗 |
| 401 | bad-credentials | 帳號密碼錯誤 |
| 401 | unauthorized | Token 無效或過期 |
| 403 | permission-denied | 無權限 |
| 404 | not-found | 資源不存在 |
| 423 | account-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 個:玫瑰花園、蘭花軒 |
| 用戶 | 10 | 10 | 各角色測試帳號 |
| 商品 | 20 | 5 | 含 7 種分類 |
| 客戶 | 15 | 3 | 個人 + 企業 |
| 訂單 | 237 | 5 | 各種狀態 |
分頁格式
使用 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() - URL 使用
${API_BASE}/api/v1/...格式(萬用字元前綴)
最佳實踐
- 所有 API 需認證 - 除登入外,一律使用
checkAuth() - 租戶隔離 - 永遠使用
tenantQuery.*()方法 - RFC 7807 錯誤 - 使用標準格式回傳錯誤
- HTTP Headers 分頁 - 不要用包裝物件
- 日誌記錄 - 所有操作都要記錄
- 萬用字元前綴 - Handler URL 使用
API_BASE = '*'以支援不同 context-path