14 KiB
14 KiB
DramaLing 錯誤處理指南
錯誤處理策略
分層錯誤處理
- API 層: 捕獲並格式化錯誤
- 業務邏輯層: 處理業務規則錯誤
- UI 層: 顯示用戶友好的錯誤信息
- 全域層: Error Boundary 捕獲未處理錯誤
錯誤類型定義
基礎錯誤類型
// types/error.ts
export class AppError extends Error {
constructor(
message: string,
public code: string,
public statusCode: number = 500,
public isOperational: boolean = true
) {
super(message)
this.name = 'AppError'
Error.captureStackTrace(this, this.constructor)
}
}
export class ValidationError extends AppError {
constructor(message: string, public fields?: Record<string, string>) {
super(message, 'VALIDATION_ERROR', 400)
this.name = 'ValidationError'
}
}
export class AuthenticationError extends AppError {
constructor(message: string = '認證失敗') {
super(message, 'AUTH_ERROR', 401)
this.name = 'AuthenticationError'
}
}
export class AuthorizationError extends AppError {
constructor(message: string = '無權限訪問') {
super(message, 'FORBIDDEN', 403)
this.name = 'AuthorizationError'
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} 不存在`, 'NOT_FOUND', 404)
this.name = 'NotFoundError'
}
}
export class RateLimitError extends AppError {
constructor(retryAfter: number) {
super('請求過於頻繁', 'RATE_LIMIT', 429)
this.name = 'RateLimitError'
this.retryAfter = retryAfter
}
retryAfter: number
}
API 錯誤處理
統一錯誤回應格式
// lib/api/error-handler.ts
import { NextResponse } from 'next/server'
import { AppError } from '@/types/error'
interface ErrorResponse {
error: {
message: string
code: string
details?: any
}
timestamp: string
path?: string
}
export function handleApiError(error: unknown, path?: string): NextResponse {
console.error('API Error:', error)
let statusCode = 500
let message = '伺服器錯誤'
let code = 'INTERNAL_ERROR'
let details = undefined
if (error instanceof AppError) {
statusCode = error.statusCode
message = error.message
code = error.code
if (error instanceof ValidationError && error.fields) {
details = error.fields
}
} else if (error instanceof Error) {
message = error.message
}
const response: ErrorResponse = {
error: {
message,
code,
details
},
timestamp: new Date().toISOString(),
path
}
return NextResponse.json(response, { status: statusCode })
}
API Route 錯誤處理範例
// app/api/flashcards/route.ts
import { handleApiError } from '@/lib/api/error-handler'
import { ValidationError } from '@/types/error'
export async function POST(request: Request) {
try {
const body = await request.json()
// 驗證輸入
if (!body.word) {
throw new ValidationError('缺少必要欄位', {
word: '單字不能為空'
})
}
// 業務邏輯
const flashcard = await createFlashcard(body)
return NextResponse.json({ flashcard }, { status: 201 })
} catch (error) {
return handleApiError(error, '/api/flashcards')
}
}
前端錯誤處理
全域 Error Boundary
// app/error.tsx
'use client'
import { useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { AlertCircle } from 'lucide-react'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// 記錄錯誤到錯誤追蹤服務
console.error('Application Error:', error)
// 可以發送到 Sentry 或其他錯誤追蹤服務
// Sentry.captureException(error)
}, [error])
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center space-y-4 p-8">
<div className="flex justify-center">
<AlertCircle className="h-12 w-12 text-destructive" />
</div>
<h2 className="text-2xl font-bold">發生錯誤</h2>
<p className="text-muted-foreground max-w-md">
{error.message || '應用程式遇到了問題,請稍後再試'}
</p>
<div className="space-x-4">
<Button onClick={reset}>重試</Button>
<Button variant="outline" onClick={() => window.location.href = '/'}>
返回首頁
</Button>
</div>
{process.env.NODE_ENV === 'development' && (
<details className="mt-4 text-left max-w-2xl mx-auto">
<summary className="cursor-pointer text-sm text-muted-foreground">
錯誤詳情
</summary>
<pre className="mt-2 text-xs bg-muted p-4 rounded overflow-auto">
{error.stack}
</pre>
</details>
)}
</div>
</div>
)
}
組件級錯誤處理
// components/error-boundary.tsx
'use client'
import React from 'react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { AlertCircle } from 'lucide-react'
interface ErrorBoundaryProps {
children: React.ReactNode
fallback?: React.ComponentType<{ error: Error; reset: () => void }>
}
interface ErrorBoundaryState {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo)
}
reset = () => {
this.setState({ hasError: false, error: null })
}
render() {
if (this.state.hasError && this.state.error) {
const Fallback = this.props.fallback
if (Fallback) {
return <Fallback error={this.state.error} reset={this.reset} />
}
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>錯誤</AlertTitle>
<AlertDescription>
{this.state.error.message}
</AlertDescription>
</Alert>
)
}
return this.props.children
}
}
表單錯誤處理
使用 React Hook Form + Zod
// lib/validations/flashcard.ts
import { z } from 'zod'
export const flashcardSchema = z.object({
word: z.string().min(1, '單字不能為空').max(100, '單字過長'),
translation: z.string().min(1, '翻譯不能為空'),
context: z.string().optional(),
example: z.string().optional(),
difficulty: z.number().min(1).max(5),
tags: z.array(z.string()).optional()
})
export type FlashcardFormData = z.infer<typeof flashcardSchema>
表單組件範例
// components/flashcard/flashcard-form.tsx
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { flashcardSchema, FlashcardFormData } from '@/lib/validations/flashcard'
import { useState } from 'react'
import { Alert, AlertDescription } from '@/components/ui/alert'
export function FlashcardForm() {
const [error, setError] = useState<string | null>(null)
const form = useForm<FlashcardFormData>({
resolver: zodResolver(flashcardSchema),
defaultValues: {
word: '',
translation: '',
difficulty: 3
}
})
const onSubmit = async (data: FlashcardFormData) => {
try {
setError(null)
const response = await fetch('/api/flashcards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error.message)
}
// 成功處理
} catch (error) {
if (error instanceof Error) {
setError(error.message)
} else {
setError('發生未知錯誤')
}
}
}
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* 表單欄位 */}
</form>
)
}
非同步錯誤處理
使用 React Query
// hooks/use-flashcards.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from '@/components/ui/use-toast'
export function useFlashcards() {
return useQuery({
queryKey: ['flashcards'],
queryFn: async () => {
const response = await fetch('/api/flashcards')
if (!response.ok) {
const error = await response.json()
throw new Error(error.error.message)
}
return response.json()
},
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000)
})
}
export function useCreateFlashcard() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: FlashcardFormData) => {
const response = await fetch('/api/flashcards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error.message)
}
return response.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['flashcards'] })
toast({
title: '成功',
description: '詞卡已建立'
})
},
onError: (error) => {
toast({
title: '錯誤',
description: error instanceof Error ? error.message : '建立失敗',
variant: 'destructive'
})
}
})
}
Supabase 錯誤處理
封裝 Supabase 客戶端
// lib/supabase/error-handler.ts
import { PostgrestError } from '@supabase/supabase-js'
import { AppError, NotFoundError, ValidationError } from '@/types/error'
export function handleSupabaseError(error: PostgrestError): never {
console.error('Supabase Error:', error)
// 處理常見錯誤碼
switch (error.code) {
case '23505': // unique_violation
throw new ValidationError('資料已存在')
case '23503': // foreign_key_violation
throw new ValidationError('關聯資料不存在')
case '23502': // not_null_violation
throw new ValidationError('缺少必要資料')
case 'PGRST116': // not found
throw new NotFoundError('資源')
default:
throw new AppError(
error.message || '資料庫操作失敗',
error.code,
500
)
}
}
// 使用範例
export async function getFlashcard(id: string) {
const { data, error } = await supabase
.from('flashcards')
.select('*')
.eq('id', id)
.single()
if (error) {
handleSupabaseError(error)
}
return data
}
錯誤日誌與監控
錯誤日誌記錄
// lib/logger.ts
type LogLevel = 'info' | 'warn' | 'error'
class Logger {
private log(level: LogLevel, message: string, data?: any) {
const timestamp = new Date().toISOString()
const logData = {
timestamp,
level,
message,
data
}
// 開發環境輸出到控制台
if (process.env.NODE_ENV === 'development') {
console[level](message, data)
}
// 生產環境發送到日誌服務
if (process.env.NODE_ENV === 'production') {
// 發送到 CloudWatch, Datadog, 等
this.sendToLogService(logData)
}
}
private sendToLogService(logData: any) {
// 實作日誌服務整合
}
info(message: string, data?: any) {
this.log('info', message, data)
}
warn(message: string, data?: any) {
this.log('warn', message, data)
}
error(message: string, error?: any) {
this.log('error', message, {
error: error instanceof Error ? {
message: error.message,
stack: error.stack
} : error
})
}
}
export const logger = new Logger()
用戶友好的錯誤信息
錯誤信息映射
// lib/error-messages.ts
export const ERROR_MESSAGES: Record<string, string> = {
// 認證錯誤
'AUTH_ERROR': '請先登入',
'INVALID_CREDENTIALS': '帳號或密碼錯誤',
'EMAIL_NOT_CONFIRMED': '請先驗證您的 Email',
// 驗證錯誤
'VALIDATION_ERROR': '輸入資料有誤',
'REQUIRED_FIELD': '此欄位為必填',
// 網路錯誤
'NETWORK_ERROR': '網路連線失敗,請檢查您的網路',
'TIMEOUT': '請求超時,請稍後再試',
// 業務錯誤
'QUOTA_EXCEEDED': '已達到使用上限',
'RATE_LIMIT': '操作過於頻繁,請稍後再試',
// 預設錯誤
'UNKNOWN_ERROR': '發生未知錯誤,請稍後再試'
}
export function getUserFriendlyMessage(errorCode: string): string {
return ERROR_MESSAGES[errorCode] || ERROR_MESSAGES['UNKNOWN_ERROR']
}
測試錯誤處理
單元測試範例
// tests/error-handler.test.ts
import { handleApiError } from '@/lib/api/error-handler'
import { ValidationError, NotFoundError } from '@/types/error'
describe('Error Handler', () => {
it('should handle ValidationError correctly', () => {
const error = new ValidationError('Invalid input', {
email: 'Email 格式錯誤'
})
const response = handleApiError(error)
const body = JSON.parse(response.body)
expect(response.status).toBe(400)
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.details).toEqual({ email: 'Email 格式錯誤' })
})
it('should handle NotFoundError correctly', () => {
const error = new NotFoundError('User')
const response = handleApiError(error)
expect(response.status).toBe(404)
})
it('should handle unknown errors', () => {
const error = new Error('Something went wrong')
const response = handleApiError(error)
expect(response.status).toBe(500)
})
})
最佳實踐
- 早期驗證: 在處理前驗證輸入
- 具體錯誤: 提供明確的錯誤信息
- 錯誤恢復: 提供重試或替代方案
- 日誌記錄: 記錄所有錯誤供調試
- 用戶友好: 顯示易懂的錯誤信息
- 安全考量: 不暴露敏感信息
- 監控告警: 設置錯誤率監控