from contextlib import asynccontextmanager from pathlib import Path import logging import threading import time from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from app.core.config import settings from app.core.database import Base, SessionLocal, engine from app.routers import admin, auth, backups, compositions, exams, export, settings as settings_router, students, subjects, wrong_questions from app.services import backup as backup_service from app.services import ocr as ocr_service from app.services.migrate import run_migrations from app.services.seed import seed_admin_and_settings, seed_subjects def resolve_frontend_dist() -> Path | None: backend_dir = Path(__file__).resolve().parent.parent dist = Path(settings.FRONTEND_DIST) if not dist.is_absolute(): dist = (backend_dir / dist).resolve() if dist.is_dir() and (dist / "index.html").is_file(): return dist return None def _auto_backup_loop() -> None: interval = max(settings.AUTO_BACKUP_INTERVAL_HOURS, 1) * 3600 time.sleep(300) while True: try: backup_service.create_backup() except Exception: logging.getLogger(__name__).exception("自动备份失败") time.sleep(interval) @asynccontextmanager async def lifespan(app: FastAPI): Path(settings.UPLOAD_DIR).mkdir(parents=True, exist_ok=True) Path(settings.BACKUP_DIR).mkdir(parents=True, exist_ok=True) Base.metadata.create_all(bind=engine) run_migrations() db = SessionLocal() try: seed_subjects(db) seed_admin_and_settings(db) finally: db.close() ocr_service.warmup_ocr_engine() if settings.AUTO_BACKUP_INTERVAL_HOURS > 0: threading.Thread(target=_auto_backup_loop, daemon=True).start() yield app = FastAPI(title="中学成绩档案", version="1.0.0", lifespan=lifespan) origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()] app.add_middleware( CORSMiddleware, allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.include_router(auth.router, prefix="/api") app.include_router(settings_router.router, prefix="/api") app.include_router(admin.router, prefix="/api") app.include_router(backups.router, prefix="/api") app.include_router(students.router, prefix="/api") app.include_router(subjects.router, prefix="/api") app.include_router(exams.router, prefix="/api") app.include_router(wrong_questions.router, prefix="/api") app.include_router(compositions.router, prefix="/api") app.include_router(export.router, prefix="/api") @app.get("/api/health") def health(): return {"status": "ok"} dist_dir = resolve_frontend_dist() if dist_dir is not None: assets_dir = dist_dir / "assets" if assets_dir.is_dir(): app.mount("/assets", StaticFiles(directory=assets_dir), name="assets") @app.api_route( "/api/{rest:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], include_in_schema=False, ) async def api_not_found(rest: str): raise HTTPException(status_code=404, detail="Not Found") @app.get("/{full_path:path}", include_in_schema=False) async def serve_spa(full_path: str): if full_path.startswith("api/") or full_path == "api": raise HTTPException(status_code=404, detail="Not Found") if full_path in ("", "index.html"): return FileResponse(dist_dir / "index.html") candidate = dist_dir / full_path if candidate.is_file(): return FileResponse(candidate) return FileResponse(dist_dir / "index.html")