dramaling-vocab-learning/note/done/BACKEND_API_STRATEGY.md

13 KiB
Raw Blame History

🚀 後端API完整策略設計

📊 現狀分析

現有功能

  • 基本詞卡CRUD操作
  • 用戶收藏功能
  • 基本資料結構完整

缺失功能

  • 分頁功能 - 不支援page/limit參數
  • 篩選功能 - difficultyLevel、partOfSpeech等參數無效
  • 排序功能 - 不支援sortBy/sortOrder參數
  • 搜尋功能 - 基本搜尋可能不完整
  • 效能索引 - 缺少資料庫索引優化

🎯 完整API設計

核心端點GET /api/flashcards

請求參數 (Query Parameters)

interface FlashcardQueryParams {
  // 搜尋和篩選
  search?: string;              // 全文搜尋 (詞彙、翻譯、定義)
  difficultyLevel?: string;     // CEFR等級 (A1, A2, B1, B2, C1, C2)
  partOfSpeech?: string;        // 詞性 (noun, verb, adjective, etc.)
  masteryLevel?: string;        // 掌握程度 (low: <60%, medium: 60-79%, high: 80%+)
  favoritesOnly?: boolean;      // 僅收藏詞卡

  // 時間範圍篩選
  createdAfter?: string;        // 創建時間起始 (ISO 8601)
  createdBefore?: string;       // 創建時間結束 (ISO 8601)
  reviewedAfter?: string;       // 最後複習時間起始
  reviewedBefore?: string;      // 最後複習時間結束

  // 複習統計篩選
  reviewCountMin?: number;      // 最少複習次數
  reviewCountMax?: number;      // 最多複習次數

  // 排序
  sortBy?: string;             // 排序字段 (createdAt, word, masteryLevel, difficultyLevel, timesReviewed)
  sortOrder?: 'asc' | 'desc';  // 排序方向

  // 分頁
  page?: number;               // 頁碼 (從1開始)
  limit?: number;              // 每頁數量 (預設20最大100)

  // 其他
  includeMeta?: boolean;       // 是否包含元數據 (預設true)
}

標準回應格式

interface FlashcardQueryResponse {
  success: boolean;
  data: {
    flashcards: Flashcard[];
    pagination: {
      current_page: number;
      total_pages: number;
      total_count: number;
      page_size: number;
      has_next: boolean;
      has_prev: boolean;
    };
    filters_applied: {
      search?: string;
      difficulty_level?: string;
      part_of_speech?: string;
      mastery_level?: string;
      favorites_only?: boolean;
      // ... 其他應用的篩選
    };
    meta?: {
      query_time_ms: number;
      cache_hit: boolean;
    };
  };
  error?: string;
}

🏗️ 後端實作策略

階段一:基礎功能修復 (必需)

1.1 分頁功能實作

def get_flashcards():
    # 分頁參數
    page = int(request.args.get('page', 1))
    limit = min(int(request.args.get('limit', 20)), 100)  # 最大100
    offset = (page - 1) * limit

    # 構建基本查詢
    query = Flashcard.query.filter_by(user_id=current_user.id)

    # 應用篩選 (後續實作)
    query = apply_filters(query, request.args)

    # 應用排序 (後續實作)
    query = apply_sorting(query, request.args)

    # 計算總數 (在分頁之前)
    total_count = query.count()

    # 執行分頁查詢
    flashcards = query.offset(offset).limit(limit).all()

    # 計算分頁資訊
    total_pages = math.ceil(total_count / limit)

    return jsonify({
        'success': True,
        'data': {
            'flashcards': [card.to_dict() for card in flashcards],
            'pagination': {
                'current_page': page,
                'total_pages': total_pages,
                'total_count': total_count,
                'page_size': limit,
                'has_next': page < total_pages,
                'has_prev': page > 1
            }
        }
    })

1.2 篩選功能實作

def apply_filters(query, args):
    """應用所有篩選條件"""

    # 全文搜尋
    search = args.get('search')
    if search:
        search_pattern = f"%{search}%"
        query = query.filter(
            db.or_(
                Flashcard.word.ilike(search_pattern),
                Flashcard.translation.ilike(search_pattern),
                Flashcard.definition.ilike(search_pattern),
                Flashcard.example.ilike(search_pattern)
            )
        )

    # CEFR等級篩選
    difficulty_level = args.get('difficultyLevel')
    if difficulty_level:
        query = query.filter(Flashcard.difficulty_level == difficulty_level)

    # 詞性篩選
    part_of_speech = args.get('partOfSpeech')
    if part_of_speech:
        query = query.filter(Flashcard.part_of_speech == part_of_speech)

    # 掌握程度篩選
    mastery_level = args.get('masteryLevel')
    if mastery_level:
        if mastery_level == 'high':
            query = query.filter(Flashcard.mastery_level >= 80)
        elif mastery_level == 'medium':
            query = query.filter(
                Flashcard.mastery_level >= 60,
                Flashcard.mastery_level < 80
            )
        elif mastery_level == 'low':
            query = query.filter(Flashcard.mastery_level < 60)

    # 收藏篩選
    favorites_only = args.get('favoritesOnly', 'false').lower() == 'true'
    if favorites_only:
        query = query.filter(Flashcard.is_favorite == True)

    # 時間範圍篩選
    created_after = args.get('createdAfter')
    if created_after:
        query = query.filter(Flashcard.created_at >= created_after)

    created_before = args.get('createdBefore')
    if created_before:
        query = query.filter(Flashcard.created_at <= created_before)

    # 複習次數篩選
    review_count_min = args.get('reviewCountMin')
    if review_count_min:
        query = query.filter(Flashcard.times_reviewed >= int(review_count_min))

    review_count_max = args.get('reviewCountMax')
    if review_count_max:
        query = query.filter(Flashcard.times_reviewed <= int(review_count_max))

    return query

1.3 排序功能實作

def apply_sorting(query, args):
    """應用排序邏輯"""
    sort_by = args.get('sortBy', 'createdAt')
    sort_order = args.get('sortOrder', 'desc')

    # 排序字段映射
    sort_fields = {
        'createdAt': Flashcard.created_at,
        'word': Flashcard.word,
        'masteryLevel': Flashcard.mastery_level,
        'difficultyLevel': Flashcard.difficulty_level,
        'timesReviewed': Flashcard.times_reviewed
    }

    if sort_by not in sort_fields:
        sort_by = 'createdAt'  # 預設排序

    sort_field = sort_fields[sort_by]

    if sort_order.lower() == 'desc':
        query = query.order_by(sort_field.desc())
    else:
        query = query.order_by(sort_field.asc())

    # CEFR等級特殊排序
    if sort_by == 'difficultyLevel':
        # 需要自定義排序邏輯 A1 < A2 < B1 < B2 < C1 < C2
        level_order = case(
            (Flashcard.difficulty_level == 'A1', 1),
            (Flashcard.difficulty_level == 'A2', 2),
            (Flashcard.difficulty_level == 'B1', 3),
            (Flashcard.difficulty_level == 'B2', 4),
            (Flashcard.difficulty_level == 'C1', 5),
            (Flashcard.difficulty_level == 'C2', 6),
            else_=7
        )

        if sort_order.lower() == 'desc':
            query = query.order_by(level_order.desc())
        else:
            query = query.order_by(level_order.asc())

    return query

階段二:資料庫優化

2.1 索引建立

-- 基本篩選索引
CREATE INDEX idx_flashcards_user_difficulty ON flashcards(user_id, difficulty_level);
CREATE INDEX idx_flashcards_user_part_of_speech ON flashcards(user_id, part_of_speech);
CREATE INDEX idx_flashcards_user_mastery ON flashcards(user_id, mastery_level);
CREATE INDEX idx_flashcards_user_favorite ON flashcards(user_id, is_favorite);

-- 時間範圍索引
CREATE INDEX idx_flashcards_user_created_at ON flashcards(user_id, created_at);
CREATE INDEX idx_flashcards_user_times_reviewed ON flashcards(user_id, times_reviewed);

-- 複合索引 (重要查詢組合)
CREATE INDEX idx_flashcards_user_difficulty_mastery ON flashcards(user_id, difficulty_level, mastery_level);
CREATE INDEX idx_flashcards_user_favorite_created ON flashcards(user_id, is_favorite, created_at);

-- 全文搜尋索引 (PostgreSQL)
CREATE INDEX idx_flashcards_fulltext ON flashcards
USING gin(to_tsvector('english', word || ' ' || translation || ' ' || definition || ' ' || example));

2.2 查詢優化

# 使用 SQLAlchemy 查詢優化
def get_optimized_flashcards():
    # 使用子查詢優化計數
    subquery = apply_filters(
        Flashcard.query.filter_by(user_id=current_user.id),
        request.args
    ).subquery()

    # 總數查詢
    total_count = db.session.query(subquery).count()

    # 分頁查詢
    query = db.session.query(Flashcard).select_from(subquery)
    query = apply_sorting(query, request.args)

    flashcards = query.offset(offset).limit(limit).all()

    return flashcards, total_count

階段三:快取策略

3.1 Redis快取

import redis
import json
import hashlib

redis_client = redis.Redis(host='localhost', port=6379, db=0)

def get_cache_key(user_id, params):
    """生成快取鍵"""
    cache_data = {
        'user_id': user_id,
        'params': dict(sorted(params.items()))
    }
    cache_string = json.dumps(cache_data, sort_keys=True)
    return f"flashcards:{hashlib.md5(cache_string.encode()).hexdigest()}"

def get_cached_flashcards(user_id, params):
    """從快取獲取結果"""
    cache_key = get_cache_key(user_id, params)
    cached_data = redis_client.get(cache_key)

    if cached_data:
        return json.loads(cached_data)

    return None

def cache_flashcards(user_id, params, data, ttl=300):
    """快取結果 (5分鐘TTL)"""
    cache_key = get_cache_key(user_id, params)
    redis_client.setex(cache_key, ttl, json.dumps(data))

3.2 快取失效策略

def invalidate_user_cache(user_id):
    """當用戶資料變更時清除快取"""
    pattern = f"flashcards:*user_id*{user_id}*"

    for key in redis_client.scan_iter(match=pattern):
        redis_client.delete(key)

# 在 CRUD 操作後調用
@app.after_request
def invalidate_cache_on_mutation(response):
    if request.method in ['POST', 'PUT', 'DELETE']:
        invalidate_user_cache(current_user.id)
    return response

🧪 API測試策略

測試案例設計

基本功能測試

# 1. 分頁測試
curl "http://localhost:5008/api/flashcards?page=1&limit=2"
curl "http://localhost:5008/api/flashcards?page=2&limit=2"

# 2. 篩選測試
curl "http://localhost:5008/api/flashcards?difficultyLevel=A2"
curl "http://localhost:5008/api/flashcards?partOfSpeech=noun"
curl "http://localhost:5008/api/flashcards?masteryLevel=low"

# 3. 排序測試
curl "http://localhost:5008/api/flashcards?sortBy=word&sortOrder=asc"
curl "http://localhost:5008/api/flashcards?sortBy=masteryLevel&sortOrder=desc"

# 4. 組合測試
curl "http://localhost:5008/api/flashcards?difficultyLevel=A2&sortBy=word&page=1&limit=5"

效能測試

# 負載測試
import time
import requests

def performance_test():
    start_time = time.time()

    response = requests.get('http://localhost:5008/api/flashcards', params={
        'search': 'test',
        'difficultyLevel': 'A2',
        'sortBy': 'createdAt',
        'page': 1,
        'limit': 20
    })

    end_time = time.time()
    response_time = (end_time - start_time) * 1000

    print(f"Response time: {response_time:.2f}ms")
    return response_time

# 目標:<300ms

📈 效能指標

成功標準

  • 回應時間: < 300ms (95th percentile)
  • 分頁查詢: < 200ms
  • 搜尋查詢: < 500ms
  • 快取命中率: > 60%
  • 資料庫連接: < 100ms

監控指標

# API監控中間件
@app.before_request
def before_request():
    g.start_time = time.time()

@app.after_request
def after_request(response):
    response_time = (time.time() - g.start_time) * 1000

    # 記錄慢查詢
    if response_time > 500:
        logger.warning(f"Slow query: {request.url} - {response_time:.2f}ms")

    # 添加效能標頭
    response.headers['X-Response-Time'] = f"{response_time:.2f}ms"

    return response

🔄 實施計劃

第1週基礎功能

  • 實作分頁功能
  • 實作基本篩選
  • 實作排序功能
  • API測試

第2週優化與擴展

  • 建立資料庫索引
  • 實作進階篩選
  • 效能優化
  • 錯誤處理

第3週快取與監控

  • 實作Redis快取
  • 效能監控
  • 負載測試
  • 文檔完善

這個完整的後端API策略確保

  • 🎯 功能完整 - 支援所有前端需求
  • 高效能 - 資料庫和快取優化
  • 🔒 穩定性 - 完整的錯誤處理
  • 📊 可監控 - 效能指標追蹤
  • 🧪 可測試 - 完整的測試覆蓋

文檔版本: 1.0 最後更新: 2025-09-24