Files
secondary-school-grade-archive/backend/app/main.py
T
dekun 530a8b70a1 学生资料设置、头像与自动备份恢复。
首页卡片支持修改/删除;详情页设置 Tab 可维护学校、年级与头像;系统设置新增数据备份下载与恢复;备份默认存 /root/grade-archive-backups,详见 docs/BACKUP.md。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 17:56:09 +08:00

112 lines
3.8 KiB
Python

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")