|
35 | 35 | ) |
36 | 36 |
|
37 | 37 | # Register routers / 注册路由 |
38 | | -app.include_router(projects_router) |
39 | | -app.include_router(cards_router) |
40 | | -app.include_router(canon_router) |
41 | | -app.include_router(drafts_router) |
42 | | -app.include_router(session_router) |
43 | | -app.include_router(config_router) |
44 | | -app.include_router(websocket_router) |
45 | | -app.include_router(fanfiction_router) |
46 | | - |
47 | | - |
48 | | -@app.get("/") |
49 | | -async def root(): |
50 | | - """Root endpoint / 根路径""" |
51 | | - return { |
52 | | - "message": "NOVIX API is running", |
53 | | - "version": "0.1.0", |
54 | | - "status": "healthy", |
55 | | - "docs": "/docs" |
56 | | - } |
| 38 | +# Register routers / 注册路由 |
| 39 | +# Strategy: Dual Mount |
| 40 | +# Mount at root "/" for Dev mode (where Vite proxy strips /api) |
| 41 | +# Mount at "/api" for Prod/EXE mode (where frontend calls /api directly) |
| 42 | +routers = [ |
| 43 | + projects_router, |
| 44 | + cards_router, |
| 45 | + canon_router, |
| 46 | + drafts_router, |
| 47 | + session_router, |
| 48 | + config_router, |
| 49 | + websocket_router, |
| 50 | + fanfiction_router |
| 51 | +] |
| 52 | + |
| 53 | +for router in routers: |
| 54 | + app.include_router(router) # Dev: http://localhost:8000/projects |
| 55 | + app.include_router(router, prefix="/api") # Prod: http://localhost:8000/api/projects |
| 56 | + |
| 57 | + |
| 58 | + |
57 | 59 |
|
58 | 60 |
|
59 | 61 | @app.get("/health") |
60 | 62 | async def health_check(): |
61 | 63 | """Health check endpoint / 健康检查""" |
62 | 64 | return {"status": "ok"} |
63 | 65 |
|
| 66 | +@app.on_event("startup") |
| 67 | +async def on_startup(): |
| 68 | + """Startup event handler / 启动事件处理""" |
| 69 | + import sys |
| 70 | + import webbrowser |
| 71 | + import asyncio |
| 72 | + |
| 73 | + # Auto-open browser in a separate thread to not block startup |
| 74 | + # But webbrowser.open is usually fire-and-forget |
| 75 | + if getattr(sys, 'frozen', False): |
| 76 | + url = f"http://localhost:{settings.port}" |
| 77 | + print(f"[Main] Auto-opening browser at {url} ...") |
| 78 | + # Small delay to ensure server is ready |
| 79 | + async def open_browser(): |
| 80 | + await asyncio.sleep(1.5) |
| 81 | + webbrowser.open(url) |
| 82 | + asyncio.create_task(open_browser()) |
| 83 | + |
| 84 | +# --- Static Files / SPA Support (Added for Packaging) --- |
| 85 | +import sys |
| 86 | +from fastapi.staticfiles import StaticFiles |
| 87 | +from fastapi.responses import FileResponse |
| 88 | +from pathlib import Path |
| 89 | + |
| 90 | +# Check where static files are located |
| 91 | +if getattr(sys, 'frozen', False): |
| 92 | + # Running as PyInstaller Bundle |
| 93 | + base_path = getattr(sys, '_MEIPASS', Path(sys.executable).parent) |
| 94 | + static_dir = Path(base_path) / "static" |
| 95 | +else: |
| 96 | + # Dev: Look for backend/static if it exists (for testing build script without freezing) |
| 97 | + static_dir = Path(__file__).parent.parent / "static" |
| 98 | + |
| 99 | +if static_dir.exists(): |
| 100 | + print(f"[Main] Serving static files from: {static_dir}") |
| 101 | + |
| 102 | + # 1. Mount assets (css, js, images) |
| 103 | + app.mount("/assets", StaticFiles(directory=str(static_dir / "assets")), name="assets") |
| 104 | + |
| 105 | + # 2. Serve Index at Root |
| 106 | + @app.get("/") |
| 107 | + async def serve_root(): |
| 108 | + return FileResponse(static_dir / "index.html") |
| 109 | + |
| 110 | + # 3. Catch-all for SPA routes (Serve index.html) |
| 111 | + @app.get("/{full_path:path}") |
| 112 | + async def serve_spa(full_path: str): |
| 113 | + # Safety: If request asks for /api/..., and we reached here, it's a 404. |
| 114 | + # Don't return HTML, otherwise frontend crashes (SyntaxError). |
| 115 | + if full_path.startswith("api/") or full_path.startswith("api"): |
| 116 | + from fastapi import HTTPException |
| 117 | + raise HTTPException(status_code=404, detail="API Endpoint Not Found") |
| 118 | + |
| 119 | + # Check if file exists in static (e.g. favicon.ico) |
| 120 | + file_path = static_dir / full_path |
| 121 | + if file_path.exists() and file_path.is_file(): |
| 122 | + return FileResponse(file_path) |
| 123 | + |
| 124 | + # Otherwise serve index.html for SPA routing |
| 125 | + return FileResponse(static_dir / "index.html") |
| 126 | +else: |
| 127 | + print("[Main] Static directory not found. Running in API-only mode (Dev).") |
| 128 | + |
64 | 129 |
|
65 | 130 | if __name__ == "__main__": |
66 | 131 | import uvicorn |
67 | | - uvicorn.run( |
68 | | - "app.main:app", |
69 | | - host=settings.host, |
70 | | - port=settings.port, |
71 | | - reload=settings.debug |
72 | | - ) |
| 132 | + import multiprocessing |
| 133 | + |
| 134 | + # Critical for Windows EXE to prevent infinite spawn loop |
| 135 | + multiprocessing.freeze_support() |
| 136 | + |
| 137 | + # Determine execution mode |
| 138 | + is_frozen = getattr(sys, 'frozen', False) |
| 139 | + |
| 140 | + if is_frozen: |
| 141 | + # Prod/EXE: Run directly with app instance, NO RELOAD |
| 142 | + # Reloading in frozen mode causes infinite subprocess spawning |
| 143 | + print("[Main] Running in Frozen (EXE) Mode") |
| 144 | + uvicorn.run( |
| 145 | + app, # Pass app instance directly, not string |
| 146 | + host=settings.host, |
| 147 | + port=settings.port, |
| 148 | + reload=False, |
| 149 | + log_level="info" |
| 150 | + ) |
| 151 | + else: |
| 152 | + # Dev: Run with reload |
| 153 | + print("[Main] Running in Dev Mode") |
| 154 | + uvicorn.run( |
| 155 | + "app.main:app", |
| 156 | + host=settings.host, |
| 157 | + port=settings.port, |
| 158 | + reload=settings.debug |
| 159 | + ) |
0 commit comments