"""报告路由(T15):触发、查询、重新生成。 投递方式:用 celery ``send_task`` 按任务名发消息,不 import apps.worker —— apps 互不依赖契约只给 tasks→worker 开了一个例外,报告的投递边靠「任务名 约定」解耦,任务代码由 worker 进程加载(见 apps/worker/report_tasks.py)。 """ from __future__ import annotations import uuid from functools import lru_cache from typing import Annotated from celery import Celery from fastapi import APIRouter, Depends, Query from fastapi.responses import FileResponse from apps.api.deps import BudgetGuardDep, CurrentUserId, SessionDep, SettingsDep from packages.report.export_service import ReportExportService from packages.report.feedback_service import FeedbackService from packages.report.schemas import ( FeedbackCreateRequest, FeedbackItem, FeedbackUpsertResponse, ReportCreateRequest, ReportExportRequest, ReportExportResponse, ReportItem, ReportRegenerateRequest, ReportSubmitResponse, ) from packages.report.service import ReportService, ReportSubmission from packages.shared.config import get_settings from packages.shared.models import RpReport from packages.shared.response import ok router = APIRouter(prefix="/reports", tags=["reports"]) REPORT_TASK_NAME = "meida.report.generate" REPORT_EXPORT_TASK_NAME = "meida.report.export" @lru_cache(maxsize=1) def _producer() -> Celery: """send_task 专用生产者:只发消息不加载任务代码,不需要 result backend。""" settings = get_settings() return Celery("meida-api", broker=settings.celery_broker_url) def _enqueue_report(task_id: int) -> None: """投进 cpu 队列(task_routes 里 meida.report.* → cpu 已配好,显式声明双保险)。""" _producer().send_task(REPORT_TASK_NAME, args=[task_id], queue="cpu") def _enqueue_export(task_id: int) -> None: """PDF 导出同属 meida.report.* → cpu 队列;投递纪律与生成一致。""" _producer().send_task(REPORT_EXPORT_TASK_NAME, args=[task_id], queue="cpu") def _serialize(report: RpReport) -> dict[str, object]: result = report.result or {} if report.status == "failed": # 失败报告只透错误摘要:半成品是流水线的中间态(给重试/重生成复用), # 不是给用户看的内容。 result = {"error": result.get("error", {})} return ReportItem( report_id=str(report.report_id), tier=report.tier, status=report.status, version=report.version, result=result, created_at=report.created_at, completed_at=report.completed_at, ).model_dump() def report_service_dep(session: SessionDep, settings: SettingsDep) -> ReportService: return ReportService(session, settings=settings, enqueue=_enqueue_report) ReportServiceDep = Annotated[ReportService, Depends(report_service_dep)] def report_export_service_dep( session: SessionDep, settings: SettingsDep, guard: BudgetGuardDep ) -> ReportExportService: return ReportExportService( session, settings=settings, scene_guard=guard, enqueue=_enqueue_export ) ReportExportServiceDep = Annotated[ReportExportService, Depends(report_export_service_dep)] def feedback_service_dep(session: SessionDep) -> FeedbackService: return FeedbackService(session) FeedbackServiceDep = Annotated[FeedbackService, Depends(feedback_service_dep)] @router.post( "/{report_id}/feedback", summary="报告维度结论反馈(T-P3-21:同维度重复 = 覆盖更新,upsert 语义)", ) async def upsert_report_feedback( report_id: int, payload: FeedbackCreateRequest, user_id: CurrentUserId, service: FeedbackServiceDep, ) -> dict[str, object]: upsert = await service.upsert_feedback( user_id=user_id, report_id=report_id, dimension_key=payload.dimension_key, verdict=payload.verdict, correction=payload.correction, ) return ok( FeedbackUpsertResponse( dimension_key=upsert.feedback.dimension_key, verdict=upsert.feedback.verdict, updated=upsert.updated, ).model_dump() ) @router.get("/{report_id}/feedback", summary="报告已提交的维度反馈列表(归属校验)") async def list_report_feedback( report_id: int, user_id: CurrentUserId, service: FeedbackServiceDep, ) -> dict[str, object]: rows = await service.list_feedback(user_id=user_id, report_id=report_id) return ok( [ FeedbackItem( dimension_key=row.dimension_key, verdict=row.verdict, correction=row.correction, created_at=row.created_at, ).model_dump() for row in rows ] ) @router.post("", summary="生成第一份美学报告(终身一次,走 report_basic 配额)") async def create_report( payload: ReportCreateRequest, user_id: CurrentUserId, service: ReportServiceDep, ) -> dict[str, object]: request_key = payload.request_key or f"report:{user_id}:{uuid.uuid4().hex}" submission = await service.create_report( user_id=user_id, questionnaire=payload.questionnaire, photo_object_key=payload.photo_object_key, request_key=request_key, ) return ok(_submit_payload(submission)) @router.get("", summary="我的报告列表(归属校验)") async def list_reports( user_id: CurrentUserId, service: ReportServiceDep, limit: int = Query(default=20, ge=1, le=100), ) -> dict[str, object]: rows = await service.list_reports(user_id=user_id, limit=limit) return ok([_serialize(row) for row in rows]) @router.get("/{report_id}", summary="报告详情(归属校验)") async def get_report( report_id: int, user_id: CurrentUserId, service: ReportServiceDep, ) -> dict[str, object]: report = await service.get_report(user_id=user_id, report_id=report_id) return ok(_serialize(report)) @router.post( "/{report_id}/regenerate", summary="重新生成(走 report_regen 月配额,复用照片与视觉结果)" ) async def regenerate_report( report_id: int, payload: ReportRegenerateRequest, user_id: CurrentUserId, service: ReportServiceDep, ) -> dict[str, object]: request_key = payload.request_key or f"report-regen:{user_id}:{report_id}:{uuid.uuid4().hex}" submission = await service.regenerate( user_id=user_id, report_id=report_id, request_key=request_key ) return ok(_submit_payload(submission)) @router.post( "/{report_id}/full", summary="生成深度诊断报告(基于该基础报告派生 tier=full,走 full_report 日配额)", ) async def derive_full_report( report_id: int, payload: ReportRegenerateRequest, user_id: CurrentUserId, service: ReportServiceDep, ) -> dict[str, object]: request_key = payload.request_key or f"report-full:{user_id}:{report_id}:{uuid.uuid4().hex}" submission = await service.derive_full_report( user_id=user_id, report_id=report_id, request_key=request_key ) return ok(_submit_payload(submission)) @router.post( "/{report_id}/export", summary="导出形象报告 PDF(走 hd_export 日配额,tier2 熔断可拦)" ) async def export_report( report_id: int, payload: ReportExportRequest, user_id: CurrentUserId, service: ReportExportServiceDep, ) -> dict[str, object]: request_key = payload.request_key or f"report-export:{user_id}:{report_id}:{uuid.uuid4().hex}" submission = await service.create_export( user_id=user_id, report_id=report_id, request_key=request_key ) return ok( ReportExportResponse(task_id=str(submission.task_id), status=submission.status).model_dump() ) @router.get("/{report_id}/export/download", summary="下载已生成的报告 PDF(归属校验)") async def download_report_pdf( report_id: int, user_id: CurrentUserId, service: ReportExportServiceDep, ) -> FileResponse: export_file = await service.get_export_file(user_id=user_id, report_id=report_id) # 二进制文件不走 ok() 信封:Content-Disposition 携带展示文件名(UTF-8 百分号编码)。 return FileResponse( export_file.path, media_type="application/pdf", filename=export_file.filename ) def _submit_payload(submission: ReportSubmission) -> dict[str, object]: return ReportSubmitResponse( task_id=str(submission.task_id), report_id=str(submission.report_id), status=submission.status, version=submission.version, ).model_dump()