255 lines
8.5 KiB
Python
255 lines
8.5 KiB
Python
"""报告路由(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()
|