""" FastAPI 主程式 本模組提供時間軸設計工具的 REST API 服務。 遵循 SDD.md 定義的 API 規範。 Author: AI Agent Version: 1.0.0 DocID: SDD-API-001 Rationale: 實現 SDD.md 第3節定義的 API 接口 """ import os import tempfile from pathlib import Path from typing import List, Optional from datetime import datetime import logging from fastapi import FastAPI, File, UploadFile, HTTPException, BackgroundTasks from fastapi.responses import FileResponse, JSONResponse from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from .schemas import ( Event, TimelineConfig, ExportOptions, Theme, ImportResult, RenderResult, APIResponse, ThemeStyle, ExportFormat ) from .importer import CSVImporter, ImporterError from .renderer_timeline import ClassicTimelineRenderer from .export import TimelineExporter, ExportError # 設定日誌 logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) # 建立 FastAPI 應用 app = FastAPI( title="TimeLine Designer API", description="時間軸設計工具 REST API", version="1.0.0", docs_url="/api/docs", redoc_url="/api/redoc" ) # 設定 CORS app.add_middleware( CORSMiddleware, allow_origins=["*"], # 在生產環境應該限制為特定來源 allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # 全域儲存(簡單起見,使用記憶體儲存,實際應用應使用資料庫) events_store: List[Event] = [] # 初始化服務 csv_importer = CSVImporter() timeline_renderer = ClassicTimelineRenderer() timeline_exporter = TimelineExporter() # ==================== 健康檢查 ==================== @app.get("/health", tags=["System"]) async def health_check(): """健康檢查端點""" return APIResponse( success=True, message="Service is healthy", data={ "version": "1.0.0", "timestamp": datetime.now().isoformat() } ) # ==================== 匯入 API ==================== @app.post("/api/import", response_model=ImportResult, tags=["Import"]) async def import_events(file: UploadFile = File(...)): """ 匯入事件資料 對應 SDD.md - POST /import 支援 CSV 和 XLSX 格式 Args: file: 上傳的檔案 Returns: ImportResult: 匯入結果 """ try: # 驗證檔案類型 if not file.filename: raise HTTPException(status_code=400, detail="未提供檔案名稱") file_ext = Path(file.filename).suffix.lower() if file_ext not in ['.csv', '.xlsx', '.xls']: raise HTTPException( status_code=400, detail=f"不支援的檔案格式: {file_ext},僅支援 CSV 和 XLSX" ) # 儲存上傳檔案到臨時目錄 with tempfile.NamedTemporaryFile(delete=False, suffix=file_ext) as tmp_file: content = await file.read() tmp_file.write(content) tmp_path = tmp_file.name try: # 匯入資料 result = csv_importer.import_file(tmp_path) if result.success: # 更新全域儲存 global events_store events_store = result.events logger.info(f"成功匯入 {result.imported_count} 筆事件") # 🔍 調試:檢查 result 的所有欄位類型 logger.debug(f"ImportResult 類型檢查:") logger.debug(f" success: {type(result.success).__name__}") logger.debug(f" total_rows: {type(result.total_rows).__name__} = {result.total_rows}") logger.debug(f" imported_count: {type(result.imported_count).__name__} = {result.imported_count}") logger.debug(f" events count: {len(result.events)}") logger.debug(f" errors count: {len(result.errors)}") return result finally: # 清理臨時檔案 os.unlink(tmp_path) except HTTPException: # Re-raise HTTP exceptions (from validation) raise except ImporterError as e: logger.error(f"匯入失敗: {str(e)}") raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"未預期的錯誤: {str(e)}") raise HTTPException(status_code=500, detail=f"伺服器錯誤: {str(e)}") # ==================== 事件管理 API ==================== @app.get("/api/events", response_model=List[Event], tags=["Events"]) async def get_events(): """ 取得事件列表 對應 SDD.md - GET /events Returns: List[Event]: 事件列表 """ return events_store @app.get("/api/events/raw", tags=["Events"]) async def get_raw_events(): """ 取得原始事件資料(用於前端 D3.js 渲染) 返回不經過任何布局計算的原始事件資料, 供前端 D3 Force-Directed Layout 使用。 Returns: dict: 包含原始事件資料的字典 """ return { "success": True, "events": [ { "id": i, "start": event.start.isoformat(), "end": event.end.isoformat() if event.end else None, "title": event.title, "description": event.description or "", "color": event.color or "#3B82F6", "layer": i % 4 # 簡單的層級分配:0-3 循環 } for i, event in enumerate(events_store) ], "count": len(events_store) } @app.post("/api/events", response_model=Event, tags=["Events"]) async def add_event(event: Event): """ 新增單一事件 Args: event: 事件物件 Returns: Event: 新增的事件 """ global events_store events_store.append(event) logger.info(f"新增事件: {event.id} - {event.title}") return event @app.delete("/api/events/{event_id}", tags=["Events"]) async def delete_event(event_id: str): """ 刪除事件 Args: event_id: 事件ID Returns: APIResponse: 操作結果 """ global events_store original_count = len(events_store) events_store = [e for e in events_store if e.id != event_id] if len(events_store) < original_count: logger.info(f"刪除事件: {event_id}") return APIResponse(success=True, message=f"成功刪除事件 {event_id}") else: raise HTTPException(status_code=404, detail=f"找不到事件: {event_id}") @app.delete("/api/events", tags=["Events"]) async def clear_events(): """ 清空所有事件 Returns: APIResponse: 操作結果 """ global events_store count = len(events_store) events_store = [] logger.info(f"清空事件,共 {count} 筆") return APIResponse(success=True, message=f"成功清空 {count} 筆事件") # ==================== 渲染 API ==================== class RenderRequest(BaseModel): """渲染請求模型""" events: Optional[List[Event]] = None config: TimelineConfig = TimelineConfig() @app.post("/api/render", response_model=RenderResult, tags=["Render"]) async def render_timeline(request: RenderRequest): """ 生成時間軸 JSON 對應 SDD.md - POST /render 生成 Plotly JSON 格式的時間軸資料 Args: request: 渲染請求(可選事件列表與配置) Returns: RenderResult: Plotly JSON 資料 """ try: # 使用請求中的事件或全域事件 events = request.events if request.events is not None else events_store if not events: logger.warning("嘗試渲染空白事件列表") # 渲染 result = timeline_renderer.render(events, request.config) if result.success: logger.info(f"成功渲染 {len(events)} 筆事件") else: logger.error("渲染失敗") return result except Exception as e: logger.error(f"渲染錯誤: {str(e)}") raise HTTPException(status_code=500, detail=f"渲染失敗: {str(e)}") # ==================== 匯出 API ==================== class ExportRequest(BaseModel): """匯出請求模型""" plotly_data: dict plotly_layout: dict options: ExportOptions filename: Optional[str] = None @app.post("/api/export", tags=["Export"]) async def export_timeline(request: ExportRequest, background_tasks: BackgroundTasks): """ 導出時間軸圖 對應 SDD.md - POST /export 匯出為 PNG、PDF 或 SVG 格式 Args: request: 匯出請求 background_tasks: 背景任務(用於清理臨時檔案) Returns: FileResponse: 圖檔 """ try: # 建立臨時輸出目錄 temp_dir = Path(tempfile.gettempdir()) / "timeline_exports" temp_dir.mkdir(exist_ok=True) # 生成檔名 if request.filename: filename = request.filename else: filename = timeline_exporter.generate_default_filename(request.options.fmt) output_path = temp_dir / filename # 匯出 result_path = timeline_exporter.export_from_plotly_json( request.plotly_data, request.plotly_layout, output_path, request.options ) logger.info(f"成功匯出: {result_path}") # 設定背景任務清理檔案(1小時後) def cleanup_file(): try: if result_path.exists(): os.unlink(result_path) logger.info(f"清理臨時檔案: {result_path}") except Exception as e: logger.warning(f"清理檔案失敗: {str(e)}") background_tasks.add_task(cleanup_file) # 回傳檔案 media_type_map = { ExportFormat.PNG: "image/png", ExportFormat.PDF: "application/pdf", ExportFormat.SVG: "image/svg+xml", } return FileResponse( path=str(result_path), media_type=media_type_map.get(request.options.fmt, "application/octet-stream"), filename=result_path.name ) except ExportError as e: logger.error(f"匯出失敗: {str(e)}") raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"未預期的錯誤: {str(e)}") raise HTTPException(status_code=500, detail=f"伺服器錯誤: {str(e)}") # ==================== 主題 API ==================== @app.get("/api/themes", response_model=List[Theme], tags=["Themes"]) async def get_themes(): """ 取得主題列表 對應 SDD.md - GET /themes Returns: List[Theme]: 主題列表 """ themes = [ Theme( name="現代風格", style=ThemeStyle.MODERN, primary_color="#3B82F6", background_color="#FFFFFF", text_color="#1F2937" ), Theme( name="經典風格", style=ThemeStyle.CLASSIC, primary_color="#6366F1", background_color="#F9FAFB", text_color="#374151" ), Theme( name="極簡風格", style=ThemeStyle.MINIMAL, primary_color="#000000", background_color="#FFFFFF", text_color="#000000" ), Theme( name="企業風格", style=ThemeStyle.CORPORATE, primary_color="#1F2937", background_color="#F3F4F6", text_color="#111827" ), ] return themes # ==================== 錯誤處理 ==================== @app.exception_handler(404) async def not_found_handler(request, exc): """404 錯誤處理""" return JSONResponse( status_code=404, content=APIResponse( success=False, message="找不到請求的資源", error_code="NOT_FOUND" ).dict() ) @app.exception_handler(500) async def internal_error_handler(request, exc): """500 錯誤處理""" logger.error(f"內部伺服器錯誤: {str(exc)}") return JSONResponse( status_code=500, content=APIResponse( success=False, message="內部伺服器錯誤", error_code="INTERNAL_ERROR" ).dict() ) # ==================== 啟動事件 ==================== @app.on_event("startup") async def startup_event(): """應用啟動時執行""" logger.info("TimeLine Designer API 啟動") logger.info("API 文檔: http://localhost:8000/api/docs") @app.on_event("shutdown") async def shutdown_event(): """應用關閉時執行""" logger.info("TimeLine Designer API 關閉") if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")