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:
xsl
2026-08-30 13:17:23 +08:00
parent cb8a1e3c0f
commit cd0ddb131b
17 changed files with 1507 additions and 5 deletions
+16
View File
@@ -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)]
+53 -1
View File
@@ -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),