dramaling-vocab-learning/docs/03_development/setup/archive/error-handling.md

14 KiB

DramaLing 錯誤處理指南

錯誤處理策略

分層錯誤處理

  1. API 層: 捕獲並格式化錯誤
  2. 業務邏輯層: 處理業務規則錯誤
  3. UI 層: 顯示用戶友好的錯誤信息
  4. 全域層: 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)
  })
})

最佳實踐

  1. 早期驗證: 在處理前驗證輸入
  2. 具體錯誤: 提供明確的錯誤信息
  3. 錯誤恢復: 提供重試或替代方案
  4. 日誌記錄: 記錄所有錯誤供調試
  5. 用戶友好: 顯示易懂的錯誤信息
  6. 安全考量: 不暴露敏感信息
  7. 監控告警: 設置錯誤率監控