feat(report): 报告 PDF 导出管线(Phase A,WeasyPrint 服务端渲染)
- POST /reports/{id}/export:request_key 幂等 + hd_export 日配额三段式(reserve/commit/refund)+ tier2 熔断场景闸(BudgetGuardDep),仅 succeeded 报告可导
- GET /reports/{id}/export/download:归属校验 + 路径收敛(rendered_asset_key 必须落在 report_export_dir 内),FileResponse 携带 UTF-8 文件名
- celery 任务 meida.report.export(cpu 队列):渲染→落盘 var/exports→回写 rendered_asset_key;业务性拒绝(报告状态变化)结案退款不重试,基础设施异常重试一次后兜底结案退款
- packages/report/pdf:Jinja2 模板 + WeasyPrint 渲染器(url_fetcher 全拒外部资源防 SSRF;维度中文名按 DIMENSION_BY_KEY 回填;色板维度渲染色卡)
- 单测 10 例(资格/幂等/并发同键/下载校验/渲染);scripts/seed_pdf_e2e.sql 供本地 E2E 造数
This commit is contained in:
@@ -191,6 +191,21 @@ def storage_service_dep(settings: SettingsDep) -> StorageService:
|
||||
return StorageService(settings)
|
||||
|
||||
|
||||
def budget_guard_dep(settings: SettingsDep) -> DailyBudgetGuard:
|
||||
"""全局日预算的场景闸(架构 §13.3):hd_export 等可降级场景在请求入口先过它。
|
||||
|
||||
每次请求新建实例是刻意的:Guard 本身无状态(水位读 Redis),
|
||||
与 build_llm_gateway 里给网关用的那只互不影响。
|
||||
"""
|
||||
return DailyBudgetGuard(
|
||||
RedisCostCounter(get_redis()),
|
||||
daily_budget_cents=Decimal(settings.llm_daily_budget_cents),
|
||||
enabled=settings.llm_daily_budget_enabled,
|
||||
tz_offset_hours=settings.llm_budget_timezone_offset_hours,
|
||||
tiers=list(settings.llm_budget_tiers),
|
||||
)
|
||||
|
||||
|
||||
def agent_service_dep(
|
||||
session: SessionDep,
|
||||
gateway: GatewayDep,
|
||||
@@ -204,4 +219,5 @@ BillingServiceDep = Annotated[BillingService, Depends(billing_service_dep)]
|
||||
TaskServiceDep = Annotated[TaskService, Depends(task_service_dep)]
|
||||
AnalyticsServiceDep = Annotated[AnalyticsService, Depends(analytics_service_dep)]
|
||||
StorageServiceDep = Annotated[StorageService, Depends(storage_service_dep)]
|
||||
BudgetGuardDep = Annotated[DailyBudgetGuard, Depends(budget_guard_dep)]
|
||||
AgentServiceDep = Annotated[AgentService, Depends(agent_service_dep)]
|
||||
|
||||
@@ -13,10 +13,14 @@ from typing import Annotated
|
||||
|
||||
from celery import Celery
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from apps.api.deps import CurrentUserId, SessionDep, SettingsDep
|
||||
from apps.api.deps import BudgetGuardDep, CurrentUserId, SessionDep, SettingsDep
|
||||
from packages.report.export_service import ReportExportService
|
||||
from packages.report.schemas import (
|
||||
ReportCreateRequest,
|
||||
ReportExportRequest,
|
||||
ReportExportResponse,
|
||||
ReportItem,
|
||||
ReportRegenerateRequest,
|
||||
ReportSubmitResponse,
|
||||
@@ -29,6 +33,7 @@ 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)
|
||||
@@ -43,6 +48,11 @@ def _enqueue_report(task_id: int) -> None:
|
||||
_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":
|
||||
@@ -67,6 +77,17 @@ def report_service_dep(session: SessionDep, settings: SettingsDep) -> ReportServ
|
||||
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)]
|
||||
|
||||
|
||||
@router.post("", summary="生成第一份美学报告(终身一次,走 report_basic 配额)")
|
||||
async def create_report(
|
||||
payload: ReportCreateRequest,
|
||||
@@ -119,6 +140,37 @@ async def regenerate_report(
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user