1
0

main.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. from fastapi import FastAPI, Request, HTTPException
  2. from contextlib import asynccontextmanager
  3. from fastapi.middleware.cors import CORSMiddleware
  4. from fastapi.responses import JSONResponse, FileResponse
  5. from fastapi.staticfiles import StaticFiles
  6. import os
  7. from app.core.config import settings
  8. from app.api.v1.api import api_router
  9. from app.db.session import db_manager
  10. from app.core.responses.base import Response
  11. from fastapi.exceptions import RequestValidationError
  12. import logging
  13. import uuid
  14. # Configure logging
  15. logging.basicConfig(
  16. level=getattr(logging, os.environ.get("LOG_LEVEL", "INFO").upper(), logging.INFO),
  17. format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
  18. )
  19. logger = logging.getLogger(__name__)
  20. settings.validate_production_security()
  21. # Initialize Database Schema
  22. try:
  23. db_manager.init_db()
  24. except Exception as e:
  25. logger.error(f"Database initialization failed: {e}", exc_info=True)
  26. # Continue to allow app to start and report error via API
  27. @asynccontextmanager
  28. async def lifespan(app: FastAPI):
  29. from app.services.forum_scheduler import scheduler
  30. recovered = await scheduler.recover_running_forums()
  31. if recovered:
  32. logger.info("Recovered running forums: %s", recovered)
  33. try:
  34. yield
  35. finally:
  36. await scheduler.shutdown()
  37. app = FastAPI(
  38. title=settings.PROJECT_NAME,
  39. openapi_url=f"{settings.API_V1_STR}/openapi.json",
  40. lifespan=lifespan,
  41. )
  42. # Global Exception Handler
  43. @app.exception_handler(Exception)
  44. async def global_exception_handler(request: Request, exc: Exception):
  45. error_id = str(uuid.uuid4())
  46. logger.exception("Unhandled request error [%s]", error_id)
  47. # Return structured error response
  48. return JSONResponse(
  49. status_code=500,
  50. content={
  51. "code": 500,
  52. "detail": "internal server error",
  53. "error_id": error_id,
  54. "message": "服务器内部错误,请稍后重试",
  55. "data": None
  56. },
  57. )
  58. @app.exception_handler(HTTPException)
  59. async def http_exception_handler(request: Request, exc: HTTPException):
  60. return JSONResponse(
  61. status_code=exc.status_code,
  62. content={
  63. "code": exc.status_code,
  64. "detail": exc.detail,
  65. "message": exc.detail,
  66. "data": None
  67. },
  68. )
  69. @app.exception_handler(RequestValidationError)
  70. async def validation_exception_handler(request: Request, exc: RequestValidationError):
  71. errors = exc.errors()
  72. logger.warning(f"Validation error: {errors}")
  73. serializable_errors = []
  74. for error in errors:
  75. item = dict(error)
  76. context = item.get("ctx")
  77. if context:
  78. item["ctx"] = {key: str(value) for key, value in context.items()}
  79. serializable_errors.append(item)
  80. return JSONResponse(
  81. status_code=400,
  82. content={
  83. "code": 400,
  84. "detail": serializable_errors,
  85. "message": "请求参数验证失败",
  86. "data": None
  87. },
  88. )
  89. # Set all CORS enabled origins
  90. app.add_middleware(
  91. CORSMiddleware,
  92. allow_origins=settings.cors_origins,
  93. allow_credentials=True,
  94. allow_methods=["*"],
  95. allow_headers=["*"],
  96. )
  97. app.include_router(api_router, prefix=settings.API_V1_STR)
  98. # Serve Frontend Static Files
  99. # In Docker/Production, we build the frontend and put it in /app/frontend/dist (as per Dockerfile)
  100. # Or ./frontend/dist relative to app root?
  101. # Dockerfile copies frontend/dist to /app/frontend/dist
  102. # But WORKDIR is /app
  103. # So path is ./frontend/dist
  104. # Let's be robust
  105. base_dir = os.path.dirname(os.path.abspath(__file__)) # /app/app
  106. root_dir = os.path.dirname(base_dir) # /app
  107. frontend_dist = os.path.join(root_dir, "frontend", "dist")
  108. if not os.path.exists(frontend_dist):
  109. # Try alternate location if running locally not in docker
  110. frontend_dist = os.path.join(root_dir, "..", "frontend", "dist")
  111. logger.info(f"Frontend dist path: {frontend_dist}, exists: {os.path.exists(frontend_dist)}")
  112. if os.path.exists(frontend_dist):
  113. app.mount("/assets", StaticFiles(directory=os.path.join(frontend_dist, "assets")), name="assets")
  114. # Catch-all for SPA routing
  115. @app.get("/{full_path:path}")
  116. async def serve_spa(full_path: str):
  117. # API requests are handled by router above (order matters? No, this is catch-all)
  118. # But include_router is already added.
  119. if full_path.startswith("api"):
  120. return JSONResponse(status_code=404, content={"detail": "API endpoint not found"})
  121. # Check if file exists (e.g. favicon.ico)
  122. file_path = os.path.join(frontend_dist, full_path)
  123. if os.path.exists(file_path) and os.path.isfile(file_path):
  124. return FileResponse(file_path)
  125. # Fallback to index.html for client-side routing
  126. index_path = os.path.join(frontend_dist, "index.html")
  127. if os.path.exists(index_path):
  128. return FileResponse(index_path)
  129. return JSONResponse(status_code=404, content={"detail": "Not Found"})
  130. @app.get("/")
  131. def root():
  132. index_path = os.path.join(frontend_dist, "index.html")
  133. if os.path.exists(index_path):
  134. return FileResponse(index_path)
  135. return {"message": "Welcome to MADF API. Frontend not found.", "docs": "/docs"}
  136. if __name__ == "__main__":
  137. import uvicorn
  138. uvicorn.run(app, host="0.0.0.0", port=8000)