from collections.abc import AsyncGenerator from contextlib import asynccontextmanager import logging from urllib.parse import quote from fastapi import Depends, FastAPI, HTTPException, Request, status from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import Response from fastapi.staticfiles import StaticFiles from sqlalchemy.exc import OperationalError, SQLAlchemyError from sqlalchemy import select from sqlalchemy.orm import Session from app.db import SessionLocal, backup_sqlite_files, get_db_session, init_db from app.models import Comment, ContentItem, Hotspot, Report from app.schemas import CreateTaskRequest, CreateTaskResponse, TaskResponse from app.services.export_service import export_hotspot_comments_csv, export_item_comments_csv, export_report_markdown from app.services.task_service import ( RUNNING_TASK_MESSAGE, create_task, get_task, has_running_task, list_tasks, recover_running_tasks, ) from app.templating import templates logger = logging.getLogger(__name__) DATABASE_UNAVAILABLE_MESSAGE = "数据库暂时不可用,请稍后重试或联系维护者恢复数据。" def handle_database_error(request: Request, exc: SQLAlchemyError): try: backup_sqlite_files() except Exception: logger.exception("Failed to back up SQLite files after database error") if request.url.path.startswith("/api/"): return JSONResponse(status_code=503, content={"detail": DATABASE_UNAVAILABLE_MESSAGE}) return templates.TemplateResponse( request, "errors/database_unavailable.html", {"message": DATABASE_UNAVAILABLE_MESSAGE, "detail": str(exc)}, status_code=503, ) @asynccontextmanager async def lifespan(_app: FastAPI) -> AsyncGenerator[None]: init_db() with SessionLocal() as session: recover_running_tasks(session) yield app = FastAPI(title="热榜评论分析工具", lifespan=lifespan) app.mount("/static", StaticFiles(directory="app/static"), name="static") @app.exception_handler(OperationalError) def operational_error_handler(request: Request, exc: OperationalError): return handle_database_error(request, exc) @app.exception_handler(SQLAlchemyError) def sqlalchemy_error_handler(request: Request, exc: SQLAlchemyError): return handle_database_error(request, exc) @app.get("/health") def health() -> dict[str, str]: return {"status": "ok"} @app.get("/", response_class=HTMLResponse) def index_page(request: Request, session: Session = Depends(get_db_session)) -> HTMLResponse: return templates.TemplateResponse( request, "index.html", {"tasks": list_tasks(session)}, ) @app.post("/api/tasks", response_model=CreateTaskResponse, status_code=status.HTTP_201_CREATED) def create_task_api( request: CreateTaskRequest, session: Session = Depends(get_db_session), ) -> CreateTaskResponse: if has_running_task(session): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=RUNNING_TASK_MESSAGE) task = create_task(session, request) return CreateTaskResponse(task_id=task.id, status=task.status) @app.get("/api/tasks", response_model=list[TaskResponse]) def list_tasks_api(session: Session = Depends(get_db_session)) -> list[TaskResponse]: return [TaskResponse.model_validate(task) for task in list_tasks(session)] @app.get("/api/tasks/{task_id}", response_model=TaskResponse) def get_task_api(task_id: str, session: Session = Depends(get_db_session)) -> TaskResponse: task = get_task(session, task_id) if task is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="任务不存在") return TaskResponse.model_validate(task) @app.get("/tasks/{task_id}", response_class=HTMLResponse) def task_detail_page(task_id: str, request: Request, session: Session = Depends(get_db_session)) -> HTMLResponse: task = get_task(session, task_id) if task is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="任务不存在") hotspots = list(session.scalars(select(Hotspot).where(Hotspot.task_id == task.id).order_by(Hotspot.rank))) return templates.TemplateResponse(request, "tasks/detail.html", {"task": task, "hotspots": hotspots}) @app.get("/hotspots/{hotspot_id}/report", response_class=HTMLResponse) def hotspot_report_page(hotspot_id: str, request: Request, session: Session = Depends(get_db_session)) -> HTMLResponse: hotspot = session.get(Hotspot, hotspot_id) if hotspot is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="热点不存在") task = get_task(session, hotspot.task_id) report = session.scalar(select(Report).where(Report.hotspot_id == hotspot.id, Report.report_type == "hotspot")) return templates.TemplateResponse(request, "hotspots/report.html", {"task": task, "hotspot": hotspot, "report": report}) @app.get("/items/{item_id}", response_class=HTMLResponse) def item_detail_page(item_id: str, request: Request, session: Session = Depends(get_db_session)) -> HTMLResponse: item = session.get(ContentItem, item_id) if item is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="内容条目不存在") hotspot = session.get(Hotspot, item.hotspot_id) task = get_task(session, item.task_id) report = session.scalar(select(Report).where(Report.content_item_id == item.id, Report.report_type == "item")) comments = list( session.scalars( select(Comment) .where(Comment.content_item_id == item.id) .order_by(Comment.like_count.desc().nullslast(), Comment.comment_time.desc().nullslast()) .limit(100) ) ) return templates.TemplateResponse( request, "items/detail.html", {"task": task, "hotspot": hotspot, "item": item, "report": report, "comments": comments}, ) @app.get("/api/export/items/{item_id}/comments.csv") def export_item_comments(item_id: str, session: Session = Depends(get_db_session)) -> Response: try: content, filename = export_item_comments_csv(session, item_id) except ValueError: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="导出数据不存在") return Response( content=content, media_type="text/csv; charset=utf-8", headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"}, ) @app.get("/api/export/hotspots/{hotspot_id}/comments.csv") def export_hotspot_comments(hotspot_id: str, session: Session = Depends(get_db_session)) -> Response: try: content, filename = export_hotspot_comments_csv(session, hotspot_id) except ValueError: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="导出数据不存在") return Response( content=content, media_type="text/csv; charset=utf-8", headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"}, ) @app.get("/api/export/hotspots/{hotspot_id}.md") def export_hotspot_markdown(hotspot_id: str, session: Session = Depends(get_db_session)) -> Response: try: content, filename = export_report_markdown(session, report_type="hotspot", hotspot_id=hotspot_id) except ValueError: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报告不存在") return Response(content=content, media_type="text/markdown; charset=utf-8", headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"}) @app.get("/api/export/items/{item_id}.md") def export_item_markdown(item_id: str, session: Session = Depends(get_db_session)) -> Response: try: content, filename = export_report_markdown(session, report_type="item", item_id=item_id) except ValueError: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报告不存在") return Response(content=content, media_type="text/markdown; charset=utf-8", headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"})