589 lines
14 KiB
Markdown
589 lines
14 KiB
Markdown
# DramaLing 錯誤處理指南
|
|
|
|
## 錯誤處理策略
|
|
|
|
### 分層錯誤處理
|
|
1. **API 層**: 捕獲並格式化錯誤
|
|
2. **業務邏輯層**: 處理業務規則錯誤
|
|
3. **UI 層**: 顯示用戶友好的錯誤信息
|
|
4. **全域層**: Error Boundary 捕獲未處理錯誤
|
|
|
|
## 錯誤類型定義
|
|
|
|
### 基礎錯誤類型
|
|
```typescript
|
|
// 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 錯誤處理
|
|
|
|
### 統一錯誤回應格式
|
|
```typescript
|
|
// 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 錯誤處理範例
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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>
|
|
)
|
|
}
|
|
```
|
|
|
|
### 組件級錯誤處理
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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>
|
|
```
|
|
|
|
### 表單組件範例
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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 客戶端
|
|
```typescript
|
|
// 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
|
|
}
|
|
```
|
|
|
|
## 錯誤日誌與監控
|
|
|
|
### 錯誤日誌記錄
|
|
```typescript
|
|
// 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()
|
|
```
|
|
|
|
## 用戶友好的錯誤信息
|
|
|
|
### 錯誤信息映射
|
|
```typescript
|
|
// 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']
|
|
}
|
|
```
|
|
|
|
## 測試錯誤處理
|
|
|
|
### 單元測試範例
|
|
```typescript
|
|
// 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)
|
|
})
|
|
})
|
|
```
|
|
|
|
## 最佳實踐
|
|
|
|
1. **早期驗證**: 在處理前驗證輸入
|
|
2. **具體錯誤**: 提供明確的錯誤信息
|
|
3. **錯誤恢復**: 提供重試或替代方案
|
|
4. **日誌記錄**: 記錄所有錯誤供調試
|
|
5. **用戶友好**: 顯示易懂的錯誤信息
|
|
6. **安全考量**: 不暴露敏感信息
|
|
7. **監控告警**: 設置錯誤率監控 |