Files
aken1023 39a4788cc4 Add PDF translation API, utilities, docs, and config
Introduces core backend and frontend infrastructure for a PDF translation interface. Adds API endpoints for translation, PDF testing, and AI provider testing; implements PDF text extraction, cost tracking, and pricing logic in the lib directory; adds reusable UI components; and provides comprehensive documentation (SDD, environment setup, Claude instructions). Updates Tailwind and global styles, and includes a sample test PDF and configuration files.
2025-10-15 23:34:44 +08:00

28 KiB
Raw Permalink Blame History

技術設計文件 (Technical Design Document)

1. 技術概述

1.1 系統架構

PDF Translation Interface 採用現代化的全端技術堆疊,基於 Next.js 15 App Router 架構,提供高效能的 PDF 文件翻譯服務。

1.2 核心技術選擇理由

前端技術選擇

  • Next.js 15 App Router: 最新的 React 框架,提供更好的 SEO 和效能
  • React 19: 最新的狀態管理和渲染優化
  • TypeScript: 強型別系統,提高程式碼品質和維護性
  • Tailwind CSS v4: 現代化的 CSS 框架,提供快速開發和優秀的自訂性

後端技術選擇

  • Next.js API Routes: 與前端整合,簡化部署和維護
  • pdf-parse + pdf2json: 多層備援的 PDF 文字擷取策略
  • Tesseract.js: 客戶端 OCR減少伺服器負載
  • PDFKit: 強大的 PDF 生成能力,支援 Unicode

2. 系統架構詳細設計

2.1 前端架構

// 主要目錄結構
/app                      // Next.js 15 App Router
├── globals.css          // 全域樣式
├── layout.tsx           // 根佈局
├── page.tsx             // 首頁
└── api/                 // API 路由
    └── translate/
        └── route.ts     // 翻譯 API 端點

/components              // React 元件
├── ui/                  // 基礎 UI 元件 (shadcn/ui)
   ├── button.tsx
   ├── card.tsx
   ├── select.tsx
   ├── label.tsx
   └── checkbox.tsx
└── pdf-translator.tsx   // 主要應用程式元件

/lib                     // 工具函式與邏輯
├── utils.ts             // 通用工具函式
├── pdf-processor.ts     // PDF 處理核心邏輯
├── pdf-to-image.ts      // PDF 轉圖片功能
└── cost-tracker.ts      // 費用追蹤系統

2.2 狀態管理架構

// PDFTranslator 元件狀態設計
interface PDFTranslatorState {
  // 檔案處理
  file: File | null
  isDragging: boolean
  
  // 語言設定
  sourceLanguage: string
  targetLanguage: string
  
  // 翻譯流程
  isTranslating: boolean
  translatedText: string
  translatedPDFBase64: string
  generatePDF: boolean
  
  // 語音功能
  isPlaying: boolean
  isPaused: boolean
  speechSupported: boolean
  selectedVoice: string
  availableVoices: SpeechSynthesisVoice[]
  speechRate: number
  speechVolume: number
  
  // 費用追蹤
  tokenUsage: TokenUsage | null
  cost: CostInfo | null
  model: ModelInfo | null
  costSummary: CostSummary | null
}

3. 核心模組技術實作

3.1 PDF 處理模組 (pdf-processor.ts)

3.1.1 多層文字擷取策略

export async function extractTextFromPDF(buffer: Buffer): Promise<PDFProcessResult> {
  // 第一層pdf-parse (主要方法)
  try {
    const pdfParseModule = await import('pdf-parse')
    const result = await pdfParseModule(buffer)
    if (result.text?.trim().length > 10) {
      return { text: result.text, pageCount: result.numpages, isScanned: false }
    }
  } catch (error) {
    console.log('pdf-parse failed, falling back to pdf2json')
  }
  
  // 第二層pdf2json (備援方法)
  try {
    const PDFParser = require('pdf2json')
    const pdfParser = new PDFParser()
    
    const parseResult = await new Promise((resolve, reject) => {
      pdfParser.on('pdfParser_dataReady', (pdfData: any) => {
        let text = ''
        if (pdfData.Pages) {
          for (const page of pdfData.Pages) {
            if (page.Texts) {
              for (const textItem of page.Texts) {
                if (textItem.R) {
                  for (const run of textItem.R) {
                    if (run.T) {
                      text += decodeURIComponent(run.T) + ' '
                    }
                  }
                }
              }
            }
            text += '\n'
          }
        }
        resolve(text.trim())
      })
      
      pdfParser.on('pdfParser_dataError', reject)
      pdfParser.parseBuffer(buffer)
    })
    
    if (parseResult && parseResult.length > 10) {
      return { text: parseResult, pageCount: 1, isScanned: false }
    }
  } catch (error) {
    console.log('pdf2json failed, PDF may be scanned')
  }
  
  // 第三層:標記為掃描文件,需要 OCR
  return { text: '', pageCount: 1, isScanned: true }
}

3.1.2 智慧 PDF 生成

export async function generateTranslatedPDF(
  translatedText: string,
  originalMetadata?: any,
  targetLanguage?: string
): Promise<Uint8Array> {
  const pdfDoc = await PDFDocument.create()
  pdfDoc.registerFontkit(fontkit)
  
  // 中文字元檢測
  const hasChinese = /[\u4e00-\u9fff]/.test(translatedText)
  
  if (hasChinese) {
    // 中文內容:使用智慧後備描述系統
    return await generateChinesePDF(pdfDoc, translatedText, targetLanguage)
  } else {
    // 非中文內容:標準 PDF 生成
    return await generateStandardPDF(pdfDoc, translatedText)
  }
}

async function generateChinesePDF(
  pdfDoc: PDFDocument,
  text: string,
  targetLanguage?: string
): Promise<Uint8Array> {
  const page = pdfDoc.addPage()
  const { width, height } = page.getSize()
  const font = await pdfDoc.embedFont(StandardFonts.Helvetica)
  
  // 添加重要提示
  page.drawText('IMPORTANT: Full Chinese translation is available in the', {
    x: 50, y: height - 100, size: 11, font, color: rgb(0.8, 0.4, 0.0)
  })
  page.drawText('text output above this PDF download button.', {
    x: 50, y: height - 115, size: 11, font, color: rgb(0.8, 0.4, 0.0)
  })
  
  // 處理中文字元並提供有意義的描述
  const lines = text.split('\n')
  let yPosition = height - 150
  
  for (const line of lines) {
    const processedLine = processChineseText(line)
    try {
      page.drawText(processedLine, {
        x: 50, y: yPosition, size: 12, font, color: rgb(0, 0, 0)
      })
    } catch (error) {
      // WinAnsi 編碼失敗時的後備處理
      const fallbackDescription = generateContentDescription(line)
      page.drawText(fallbackDescription, {
        x: 50, y: yPosition, size: 12, font, color: rgb(0.3, 0.3, 0.3)
      })
    }
    yPosition -= 15
  }
  
  return await pdfDoc.save()
}

function generateContentDescription(chineseText: string): string {
  // 基於內容提供有意義的英文描述
  if (chineseText.includes('測試')) return 'Testing PDF processing'
  if (chineseText.includes('文字提取')) return 'Text extraction functionality'
  if (chineseText.includes('翻譯')) return 'Translation process'
  return 'Translated Chinese content'
}

3.2 費用追蹤模組 (cost-tracker.ts)

interface CostSession {
  id: string
  timestamp: Date
  provider: string
  model: string
  tokenUsage: {
    prompt: number
    completion: number
    total: number
  }
  cost: {
    inputCost: number
    outputCost: number
    totalCost: number
    currency: string
  }
}

interface CostSummary {
  totalSessions: number
  totalTokens: number
  totalCost: number
  currency: string
  byProvider: Record<string, {
    sessions: number
    tokens: number
    cost: number
  }>
}

class CostTracker {
  private readonly STORAGE_KEY = 'pdf-translator-costs'
  
  addCostSession(session: CostSession): CostSummary {
    const sessions = this.getCostSessions()
    sessions.push(session)
    
    if (typeof window !== 'undefined') {
      localStorage.setItem(this.STORAGE_KEY, JSON.stringify(sessions))
    }
    
    return this.calculateSummary(sessions)
  }
  
  getCostSummary(): CostSummary {
    const sessions = this.getCostSessions()
    return this.calculateSummary(sessions)
  }
  
  private calculateSummary(sessions: CostSession[]): CostSummary {
    return sessions.reduce((summary, session) => ({
      totalSessions: summary.totalSessions + 1,
      totalTokens: summary.totalTokens + session.tokenUsage.total,
      totalCost: summary.totalCost + session.cost.totalCost,
      currency: session.cost.currency,
      byProvider: {
        ...summary.byProvider,
        [session.provider]: {
          sessions: (summary.byProvider[session.provider]?.sessions || 0) + 1,
          tokens: (summary.byProvider[session.provider]?.tokens || 0) + session.tokenUsage.total,
          cost: (summary.byProvider[session.provider]?.cost || 0) + session.cost.totalCost
        }
      }
    }), {
      totalSessions: 0,
      totalTokens: 0,
      totalCost: 0,
      currency: 'USD',
      byProvider: {}
    })
  }
}

export const costTracker = new CostTracker()

3.3 語音播放模組

// 語音功能整合在 PDFTranslator 元件中
const playText = () => {
  if (!speechSupported || !translatedText) return
  
  // 恢復播放
  if (isPaused) {
    speechSynthesis.resume()
    setIsPaused(false)
    setIsPlaying(true)
    return
  }
  
  // 新的播放
  speechSynthesis.cancel()
  const utterance = new SpeechSynthesisUtterance(translatedText)
  
  // 語音配置
  if (selectedVoice) {
    const voice = availableVoices.find(v => v.name === selectedVoice)
    if (voice) utterance.voice = voice
  }
  
  utterance.rate = speechRate
  utterance.volume = speechVolume
  utterance.pitch = 1.0
  
  // 事件處理
  utterance.onstart = () => setIsPlaying(true)
  utterance.onend = () => { setIsPlaying(false); setIsPaused(false) }
  utterance.onerror = (event) => {
    console.error('Speech synthesis error:', event.error)
    setIsPlaying(false)
    setIsPaused(false)
  }
  
  speechSynthesis.speak(utterance)
}

// 自動語音選擇
const findPreferredVoice = (voices: SpeechSynthesisVoice[], langCode: string) => {
  const langMap: Record<string, string[]> = {
    'zh-TW': ['zh-TW', 'zh-HK', 'zh'],
    'zh-CN': ['zh-CN', 'zh'],
    'en': ['en-US', 'en-GB', 'en'],
    'ja': ['ja-JP', 'ja'],
    'ko': ['ko-KR', 'ko'],
    // ... 其他語言映射
  }
  
  const targetLangs = langMap[langCode] || [langCode]
  
  for (const targetLang of targetLangs) {
    const voice = voices.find(v => v.lang.startsWith(targetLang))
    if (voice) return voice
  }
  
  return voices[0] // 後備選項
}

4. API 設計與實作

4.1 翻譯 API (app/api/translate/route.ts)

export async function POST(request: Request) {
  try {
    const formData = await request.formData()
    const file = formData.get('file') as File
    const sourceLanguage = formData.get('sourceLanguage') as string
    const targetLanguage = formData.get('targetLanguage') as string
    const returnPDF = formData.get('returnPDF') === 'true'
    
    // 檔案驗證
    if (!file || file.type !== 'application/pdf') {
      return NextResponse.json({ error: '請上傳有效的 PDF 檔案' }, { status: 400 })
    }
    
    if (file.size > 10 * 1024 * 1024) { // 10MB 限制
      return NextResponse.json({ error: '檔案大小不能超過 10MB' }, { status: 413 })
    }
    
    // PDF 文字擷取
    const buffer = Buffer.from(await file.arrayBuffer())
    const result = await extractTextFromPDF(buffer)
    
    if (!result.text.trim()) {
      return NextResponse.json({ 
        error: 'PDF 文字提取失敗,可能是掃描檔案或加密文件' 
      }, { status: 400 })
    }
    
    // AI 翻譯
    const { translatedText, tokenUsage, cost, model } = await translateText(
      result.text,
      sourceLanguage,
      targetLanguage
    )
    
    // 費用追蹤
    const costSession = {
      id: generateId(),
      timestamp: new Date(),
      provider: model.provider,
      model: model.name,
      tokenUsage,
      cost
    }
    
    // PDF 生成(可選)
    let pdfBase64: string | undefined
    if (returnPDF) {
      const pdfBytes = await generateTranslatedPDF(translatedText, result.metadata, targetLanguage)
      pdfBase64 = Buffer.from(pdfBytes).toString('base64')
    }
    
    return NextResponse.json({
      translatedText,
      pdfBase64,
      tokenUsage: {
        ...tokenUsage,
        formattedCounts: {
          prompt: formatNumber(tokenUsage.prompt),
          completion: formatNumber(tokenUsage.completion),
          total: formatNumber(tokenUsage.total)
        }
      },
      cost: {
        ...cost,
        formattedCost: formatCost(cost.totalCost, cost.currency)
      },
      model: {
        name: model.name,
        provider: model.provider,
        displayName: model.displayName
      },
      costSession
    })
    
  } catch (error) {
    console.error('Translation error:', error)
    return NextResponse.json(
      { error: error instanceof Error ? error.message : '翻譯過程中發生錯誤' },
      { status: 500 }
    )
  }
}

4.2 AI 翻譯整合

import { createOpenAI } from '@ai-sdk/openai'
import { generateText } from 'ai'

const deepseek = createOpenAI({
  apiKey: process.env.DEEPSEEK_API_KEY!,
  baseURL: process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/v1',
})

const openai = createOpenAI({
  apiKey: process.env.OPENAI_API_KEY!,
})

export async function translateText(
  text: string,
  sourceLanguage: string,
  targetLanguage: string
) {
  const provider = process.env.AI_PROVIDER || 'deepseek'
  
  const model = provider === 'deepseek' 
    ? deepseek(process.env.DEEPSEEK_MODEL || 'deepseek-chat')
    : openai(process.env.OPENAI_MODEL || 'gpt-4o-mini')
  
  const prompt = createTranslationPrompt(text, sourceLanguage, targetLanguage)
  
  const result = await generateText({
    model,
    prompt,
    maxTokens: 4000,
    temperature: 0.3,
  })
  
  // Token 使用量計算
  const tokenUsage = {
    prompt: result.usage?.promptTokens || 0,
    completion: result.usage?.completionTokens || 0,
    total: result.usage?.totalTokens || 0
  }
  
  // 費用計算
  const cost = calculateCost(tokenUsage, provider, model.modelId)
  
  return {
    translatedText: result.text,
    tokenUsage,
    cost,
    model: {
      name: model.modelId,
      provider,
      displayName: getModelDisplayName(model.modelId, provider)
    }
  }
}

function createTranslationPrompt(text: string, source: string, target: string): string {
  const targetLang = LANGUAGE_NAMES[target] || target
  const sourceLang = source === 'auto' ? '自動偵測' : LANGUAGE_NAMES[source] || source
  
  return `請將以下${sourceLang}文字翻譯成${targetLang},保持原文的格式和段落結構,確保翻譯準確且自然:

${text}

請直接提供翻譯結果,不需要額外說明。`
}

5. UI/UX 技術實作

5.1 文清楓風格設計系統

// Tailwind CSS 自訂配置
const designTokens = {
  colors: {
    // 文清楓主色調
    primary: {
      amber: 'rgb(245 158 11)', // amber-500
      green: 'rgb(34 197 94)',  // green-500
      teal: 'rgb(20 184 166)',  // teal-500
    },
    
    // 背景漸變
    background: {
      light: 'from-amber-50 via-green-50 to-teal-50',
      dark: 'dark:from-slate-800 dark:via-slate-900 dark:to-slate-800',
    },
    
    // 裝飾元素
    decoration: {
      circles: [
        'bg-amber-200 blur-xl',
        'bg-green-200 blur-xl',
        'bg-teal-200 blur-xl',
        'bg-amber-300 blur-xl'
      ]
    }
  },
  
  spacing: {
    responsive: {
      mobile: 'p-4 gap-4',
      tablet: 'sm:p-6 sm:gap-6', 
      desktop: 'lg:p-8 lg:gap-8'
    }
  }
}

5.2 響應式設計實作

// 響應式斷點策略
const breakpoints = {
  sm: '640px',   // 手機橫向
  md: '768px',   // 平板直向  
  lg: '1024px',  // 平板橫向
  xl: '1280px',  // 桌面
  '2xl': '1536px' // 大桌面
}

// 響應式元件設計模式
const ResponsiveComponent = () => (
  <div className="
    grid grid-cols-1 xl:grid-cols-2 
    gap-4 sm:gap-6 lg:gap-8
    p-4 sm:p-6 lg:p-8
  ">
    <Card className="
      p-4 sm:p-6 lg:p-8
      text-sm sm:text-base lg:text-lg
    ">
      {/* 內容 */}
    </Card>
  </div>
)

// 行動裝置優化
const MobileOptimizedButton = ({ children, fullText, iconText }) => (
  <Button className="
    text-xs sm:text-sm lg:text-base
    px-2 sm:px-4 lg:px-6
    h-8 sm:h-10 lg:h-12
  ">
    <Icon className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
    <span className="hidden sm:inline">{fullText}</span>
    <span className="sm:hidden">{iconText}</span>
  </Button>
)

5.3 拖拽上傳實作

const DragDropUpload = () => {
  const [isDragging, setIsDragging] = useState(false)
  
  const handleDragOver = (e: React.DragEvent) => {
    e.preventDefault()
    setIsDragging(true)
  }
  
  const handleDragLeave = () => {
    setIsDragging(false)
  }
  
  const handleDrop = (e: React.DragEvent) => {
    e.preventDefault()
    setIsDragging(false)
    
    const droppedFile = e.dataTransfer.files[0]
    if (droppedFile && droppedFile.type === 'application/pdf') {
      setFile(droppedFile)
    } else {
      alert('目前僅支援 PDF 文件')
    }
  }
  
  return (
    <div
      className={`
        border-2 border-dashed rounded-xl p-6 sm:p-8 lg:p-12
        text-center transition-all duration-300
        ${isDragging 
          ? 'border-green-500 bg-green-50/50 shadow-lg' 
          : 'border-amber-300 hover:border-green-400'
        }
      `}
      onDragOver={handleDragOver}
      onDragLeave={handleDragLeave}
      onDrop={handleDrop}
    >
      {/* 拖拽區域內容 */}
    </div>
  )
}

6. 效能優化策略

6.1 前端效能優化

// 1. 元件懶加載
const PDFTranslator = dynamic(() => import('@/components/pdf-translator'), {
  loading: () => <div className="animate-pulse">載入中...</div>,
  ssr: false // 避免語音 API 的 SSR 問題
})

// 2. 記憶化計算
const memoizedCostSummary = useMemo(() => {
  return costTracker.getCostSummary()
}, [costSummary])

// 3. 防抖動處理
const debouncedSpeechRateChange = useCallback(
  debounce((rate: number) => setSpeechRate(rate), 300),
  []
)

// 4. 批次狀態更新
const updateTranslationResult = useCallback((data: TranslationResult) => {
  // 批次更新避免多次重渲染
  setTranslatedText(data.translatedText)
  setTranslatedPDFBase64(data.pdfBase64 || '')
  setTokenUsage(data.tokenUsage)
  setCost(data.cost)
  setModel(data.model)
  
  if (data.costSession) {
    const updatedSummary = costTracker.addCostSession(data.costSession)
    setCostSummary(updatedSummary)
  }
}, [])

6.2 後端效能優化

// 1. 串流處理大檔案
export async function processLargeFile(buffer: Buffer) {
  const stream = new Readable({
    read() {
      // 分塊處理邏輯
    }
  })
  
  return new Promise((resolve, reject) => {
    const chunks: Buffer[] = []
    stream.on('data', chunk => chunks.push(chunk))
    stream.on('end', () => resolve(Buffer.concat(chunks)))
    stream.on('error', reject)
  })
}

// 2. PDF 處理快取
const pdfCache = new Map<string, PDFProcessResult>()

export async function cachedExtractTextFromPDF(buffer: Buffer): Promise<PDFProcessResult> {
  const hash = createHash('md5').update(buffer).digest('hex')
  
  if (pdfCache.has(hash)) {
    return pdfCache.get(hash)!
  }
  
  const result = await extractTextFromPDF(buffer)
  pdfCache.set(hash, result)
  
  // 限制快取大小
  if (pdfCache.size > 100) {
    const firstKey = pdfCache.keys().next().value
    pdfCache.delete(firstKey)
  }
  
  return result
}

// 3. AI API 請求優化
const rateLimiter = new Map<string, number>()

export async function rateLimitedTranslation(
  text: string, 
  source: string, 
  target: string
) {
  const key = `${source}-${target}`
  const lastRequest = rateLimiter.get(key) || 0
  const now = Date.now()
  
  if (now - lastRequest < 1000) { // 1秒限制
    await new Promise(resolve => setTimeout(resolve, 1000 - (now - lastRequest)))
  }
  
  rateLimiter.set(key, Date.now())
  return await translateText(text, source, target)
}

7. 錯誤處理策略

7.1 分層錯誤處理

// 1. API 層錯誤處理
export class TranslationError extends Error {
  constructor(
    message: string,
    public code: string,
    public statusCode: number = 500
  ) {
    super(message)
    this.name = 'TranslationError'
  }
}

export class PDFProcessingError extends TranslationError {
  constructor(message: string) {
    super(message, 'PDF_PROCESSING_ERROR', 400)
  }
}

export class AIServiceError extends TranslationError {
  constructor(message: string, provider: string) {
    super(`${provider} 服務錯誤: ${message}`, 'AI_SERVICE_ERROR', 502)
  }
}

// 2. 全域錯誤處理中間件
export async function errorHandler(
  error: Error,
  request: Request
): Promise<NextResponse> {
  console.error('API Error:', error)
  
  if (error instanceof TranslationError) {
    return NextResponse.json(
      { error: error.message, code: error.code },
      { status: error.statusCode }
    )
  }
  
  // 未知錯誤
  return NextResponse.json(
    { error: '內部伺服器錯誤', code: 'INTERNAL_ERROR' },
    { status: 500 }
  )
}

// 3. 前端錯誤邊界
export function ErrorBoundary({ children }: { children: React.ReactNode }) {
  return (
    <ErrorBoundaryComponent
      fallback={({ error, resetErrorBoundary }) => (
        <div className="text-center p-8">
          <h2 className="text-xl font-semibold mb-4">系統發生錯誤</h2>
          <p className="text-gray-600 mb-4">{error.message}</p>
          <Button onClick={resetErrorBoundary}>重新載入</Button>
        </div>
      )}
    >
      {children}
    </ErrorBoundaryComponent>
  )
}

7.2 使用者友善的錯誤提示

const errorMessages = {
  PDF_TOO_LARGE: '檔案大小超過 10MB 限制,請選擇較小的檔案',
  PDF_CORRUPTED: 'PDF 檔案損壞或加密,無法處理',
  PDF_NO_TEXT: 'PDF 中未找到可擷取的文字,可能為純圖片檔案',
  AI_QUOTA_EXCEEDED: 'AI 服務配額已達上限,請稍後再試',
  AI_RATE_LIMITED: '請求過於頻繁,請稍等片刻後再試',
  NETWORK_ERROR: '網路連線失敗,請檢查網路狀況',
  UNSUPPORTED_LANGUAGE: '不支援的語言組合'
}

export function getErrorMessage(error: unknown): string {
  if (error instanceof Error) {
    return errorMessages[error.message] || error.message
  }
  return '發生未知錯誤,請重新嘗試'
}

8. 測試策略

8.1 單元測試

// PDF 處理測試
describe('PDF Processing', () => {
  test('should extract text from text-based PDF', async () => {
    const mockBuffer = Buffer.from('mock-pdf-content')
    const result = await extractTextFromPDF(mockBuffer)
    
    expect(result.text).toBeDefined()
    expect(result.pageCount).toBeGreaterThan(0)
    expect(result.isScanned).toBe(false)
  })
  
  test('should handle corrupted PDF gracefully', async () => {
    const corruptedBuffer = Buffer.from('invalid-pdf')
    
    await expect(extractTextFromPDF(corruptedBuffer))
      .rejects.toThrow(PDFProcessingError)
  })
})

// 費用追蹤測試
describe('Cost Tracking', () => {
  beforeEach(() => {
    localStorage.clear()
  })
  
  test('should calculate cost summary correctly', () => {
    const session: CostSession = {
      id: 'test-1',
      timestamp: new Date(),
      provider: 'deepseek',
      model: 'deepseek-chat',
      tokenUsage: { prompt: 100, completion: 50, total: 150 },
      cost: { inputCost: 0.001, outputCost: 0.002, totalCost: 0.003, currency: 'USD' }
    }
    
    const summary = costTracker.addCostSession(session)
    
    expect(summary.totalSessions).toBe(1)
    expect(summary.totalTokens).toBe(150)
    expect(summary.totalCost).toBe(0.003)
  })
})

8.2 整合測試

// API 端點測試
describe('/api/translate', () => {
  test('should translate PDF successfully', async () => {
    const formData = new FormData()
    formData.append('file', new File(['mock-pdf'], 'test.pdf', { type: 'application/pdf' }))
    formData.append('sourceLanguage', 'zh-TW')
    formData.append('targetLanguage', 'en')
    formData.append('returnPDF', 'true')
    
    const response = await fetch('/api/translate', {
      method: 'POST',
      body: formData
    })
    
    expect(response.status).toBe(200)
    
    const data = await response.json()
    expect(data.translatedText).toBeDefined()
    expect(data.tokenUsage).toBeDefined()
    expect(data.cost).toBeDefined()
  })
})

8.3 端對端測試

// Playwright E2E 測試
test('complete translation workflow', async ({ page }) => {
  await page.goto('/')
  
  // 上傳檔案
  await page.setInputFiles('input[type="file"]', 'test-files/sample.pdf')
  
  // 選擇語言
  await page.selectOption('[data-testid="source-language"]', 'zh-TW')
  await page.selectOption('[data-testid="target-language"]', 'en')
  
  // 開始翻譯
  await page.click('[data-testid="translate-button"]')
  
  // 等待結果
  await page.waitForSelector('[data-testid="translation-result"]')
  
  // 驗證結果
  const result = await page.textContent('[data-testid="translation-result"]')
  expect(result).toBeTruthy()
  
  // 測試語音播放
  await page.click('[data-testid="play-button"]')
  await page.waitForTimeout(1000)
  await page.click('[data-testid="pause-button"]')
})

9. 部署與監控

9.1 Vercel 部署配置

{
  "buildCommand": "npm run build",
  "outputDirectory": ".next",
  "installCommand": "npm install",
  "framework": "nextjs",
  "functions": {
    "app/api/translate/route.ts": {
      "maxDuration": 30
    }
  },
  "env": {
    "NODE_ENV": "production"
  }
}

9.2 效能監控

// 自訂效能監控
export class PerformanceMonitor {
  static measureApiCall(name: string) {
    const start = performance.now()
    
    return {
      end: (status: 'success' | 'error') => {
        const duration = performance.now() - start
        
        // 發送監控數據
        if (typeof window !== 'undefined') {
          navigator.sendBeacon('/api/metrics', JSON.stringify({
            name,
            duration,
            status,
            timestamp: Date.now()
          }))
        }
      }
    }
  }
}

// 使用範例
export async function monitoredTranslation(params: TranslationParams) {
  const monitor = PerformanceMonitor.measureApiCall('translation')
  
  try {
    const result = await translateText(params)
    monitor.end('success')
    return result
  } catch (error) {
    monitor.end('error')
    throw error
  }
}

10. 安全性實作

10.1 輸入驗證

import { z } from 'zod'

const TranslationRequestSchema = z.object({
  file: z.instanceof(File)
    .refine(file => file.type === 'application/pdf', '僅支援 PDF 檔案')
    .refine(file => file.size <= 10 * 1024 * 1024, '檔案大小不能超過 10MB'),
  sourceLanguage: z.enum(SUPPORTED_LANGUAGES),
  targetLanguage: z.enum(SUPPORTED_LANGUAGES),
  returnPDF: z.boolean().optional()
})

export async function validateRequest(formData: FormData) {
  const data = {
    file: formData.get('file'),
    sourceLanguage: formData.get('sourceLanguage'),
    targetLanguage: formData.get('targetLanguage'),
    returnPDF: formData.get('returnPDF') === 'true'
  }
  
  return TranslationRequestSchema.parse(data)
}

10.2 API 金鑰管理

// 環境變數驗證
const envSchema = z.object({
  DEEPSEEK_API_KEY: z.string().min(1, 'DeepSeek API key is required'),
  OPENAI_API_KEY: z.string().optional(),
  AI_PROVIDER: z.enum(['deepseek', 'openai']).default('deepseek'),
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development')
})

export const env = envSchema.parse(process.env)

// API 金鑰輪換
class APIKeyManager {
  private keys: string[]
  private currentIndex = 0
  
  constructor(keys: string[]) {
    this.keys = keys.filter(Boolean)
    if (this.keys.length === 0) {
      throw new Error('No valid API keys provided')
    }
  }
  
  getKey(): string {
    const key = this.keys[this.currentIndex]
    this.currentIndex = (this.currentIndex + 1) % this.keys.length
    return key
  }
}

11. 未來技術升級計畫

11.1 短期升級 (1-3 個月)

  • 實作 Redis 快取系統
  • 添加請求速率限制
  • 整合 Sentry 錯誤監控
  • 實作批次檔案處理

11.2 中期升級 (3-6 個月)

  • 實作 WebSocket 即時進度更新
  • 整合更多 AI 模型 (Claude, Gemini)
  • 實作用戶認證系統
  • 添加翻譯歷史功能

11.3 長期升級 (6-12 個月)

  • 實作分散式處理系統
  • 整合專業 CAT 工具
  • 實作協作翻譯功能
  • 添加 API 開放平台

11.4 技術債務管理

  • 重構 PDF 處理模組為微服務
  • 實作完整的測試覆蓋率 (目標 >90%)
  • 優化 TypeScript 型別定義
  • 實作自動化效能測試

本技術設計文件將隨著系統演進持續更新,確保技術架構的可維護性和可擴展性。