from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from urllib.parse import quote from fastapi import Depends, FastAPI, HTTPException, Request, status from fastapi.responses import HTMLResponse from fastapi.responses import Response from fastapi.staticfiles import StaticFiles from sqlalchemy import select from sqlalchemy.orm import Session from app.db import SessionLocal, 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 @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.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)}"})