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),
+1 -1
View File
@@ -27,7 +27,7 @@ celery_app = Celery(
"meida",
broker=settings.celery_broker_url,
backend=settings.celery_result_backend,
include=["apps.worker.tasks", "apps.worker.report_tasks"],
include=["apps.worker.tasks", "apps.worker.report_tasks", "apps.worker.export_tasks"],
)
celery_app.conf.update(
+183
View File
@@ -0,0 +1,183 @@
"""报告 PDF 导出的 celery 任务(name=``meida.report.export``,cpu 队列)。
只做「取产物 → 渲染 → 落盘 → 回写 rendered_asset_key」,不重算内容。
配额三段式与报告生成一致:成功 commit、失败 refund(FR-BILL-05),
预扣凭 ``tk_task.reserve_tx_id`` 找回(复用 pipeline.load_reservation)。
渲染是同步 CPU 工作(WeasyPrint 没有 async API),在 --pool=solo 的 cpu
worker 里直接执行——单份报告渲染耗时为秒级,可接受;未来上量再挪线程池。
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, cast
from apps.worker import loop as worker_loop
from apps.worker.celery_app import celery_app
from packages.billing.service import BillingService
from packages.domain.task.state import TERMINAL_STATUSES, TaskStatus
from packages.report import repo as report_repo
from packages.report.pdf.renderer import ReportPdfMeta, render_report_pdf
from packages.report.pipeline import load_reservation
from packages.shared.config import get_settings
from packages.shared.context import bind_context
from packages.shared.db import get_session_factory
from packages.shared.errors import AppError, ErrorCode
from packages.shared.logging import get_logger
from packages.task import repo as task_repo
from packages.task.service import TaskService
logger = get_logger(__name__)
class ExportRejectedError(Exception):
"""业务性拒绝(如报告不再是 succeeded):已在任务内结案并退款,重跑也不会好。
与流水线的 StageFailureError 同义:celery 包装层据类型区分「业务失败
不重试」与「基础设施异常值得重试」。
"""
def _write_pdf(export_dir: str, filename: str, pdf_bytes: bytes) -> Path:
"""落盘导出文件(同步:pathlib 阻塞方法不进 async 函数,ASYNC240)。
返回含导出目录前缀的相对路径——rendered_asset_key 按此入库,下载侧
按同一 cwd 解析,且整体迁移 var/ 目录时不用改库。
"""
directory = Path(export_dir)
directory.mkdir(parents=True, exist_ok=True)
path = directory / filename
path.write_bytes(pdf_bytes)
return path
async def _export_report(task_id: int) -> dict[str, Any]:
"""执行一次报告导出(celery 包装层的内芯)。"""
settings = get_settings()
factory = get_session_factory()
async with factory() as session:
task = await task_repo.get_for_update(session, task_id)
if task is None:
raise AppError(ErrorCode.NOT_FOUND, message=f"任务不存在:{task_id}")
current = TaskStatus(task.status)
if current in TERMINAL_STATUSES:
# 重复投递/幂等重放的守卫:已结案的任务不再执行,防重复 commit 配额。
logger.info("report_export_replay_skipped", task_id=task_id, status=task.status)
return {"task_id": str(task_id), "status": task.status, "skipped": True}
report_id_raw = task.payload.get("report_id")
if not isinstance(report_id_raw, str | int):
raise AppError(ErrorCode.STATE_CONFLICT, message="任务缺少 report_id")
report_id = int(report_id_raw)
report = await report_repo.get_report(session, report_id)
if report is None:
raise AppError(ErrorCode.STATE_CONFLICT, message="报告数据不完整")
service = TaskService(session)
# 与报告生成同一纪律:重试入站先归一 QUEUED 再进 RUNNING。
if current is not TaskStatus.QUEUED:
await service.transition(task_id=task_id, target=TaskStatus.QUEUED, stage_text="排队中")
await service.transition(task_id=task_id, target=TaskStatus.RUNNING, stage_text="渲染中")
await session.commit() # RUNNING 先落库:前端进度读它
if report.status != report_repo.STATUS_SUCCEEDED:
# 发起导出后报告被重生成顶掉/置失败等:业务性拒绝,结案退款不重试。
await _reject_export(task_id, "报告当前状态不可导出")
raise ExportRejectedError(f"report {report_id} status={report.status}")
pdf_bytes = render_report_pdf(
report.result or {},
meta=ReportPdfMeta(
report_id=report.report_id,
version=report.version,
generated_at=report.completed_at or report.created_at,
),
)
filename = f"report_{report.report_id}_v{report.version}.pdf"
asset_key = _write_pdf(settings.report_export_dir, filename, pdf_bytes).as_posix()
report.rendered_asset_key = asset_key
reservation = await load_reservation(session, task_id)
if reservation is not None:
await BillingService(session).commit(reservation)
await service.transition(
task_id=task_id,
target=TaskStatus.SUCCEEDED,
stage_text="已完成",
result={
"report_id": str(report.report_id),
"version": report.version,
"asset_key": asset_key,
},
)
await session.commit()
logger.info(
"report_export_succeeded",
report_id=report_id,
task_id=task_id,
asset_key=asset_key,
pdf_bytes=len(pdf_bytes),
)
return {"task_id": str(task_id), "report_id": str(report_id), "status": "succeeded"}
async def _reject_export(task_id: int, message: str) -> None:
"""业务性拒绝的结案:task failed + 退款,提交后才返回(纪律同流水线失败结案)。"""
factory = get_session_factory()
async with factory() as session:
await TaskService(session).transition(
task_id=task_id,
target=TaskStatus.FAILED,
stage_text="无法导出",
error_message=message,
)
reservation = await load_reservation(session, task_id)
if reservation is not None:
await BillingService(session).refund(reservation, reason="report_export_rejected")
await session.commit()
async def _fail_export(task_id: int, message: str) -> None:
"""基础设施异常重试耗尽后的兜底结案:failed + 退款(尽力而为)。"""
factory = get_session_factory()
try:
async with factory() as session:
task = await task_repo.get_for_update(session, task_id)
if task is None or TaskStatus(task.status) in TERMINAL_STATUSES:
return
await TaskService(session).transition(
task_id=task_id,
target=TaskStatus.FAILED,
stage_text="执行失败",
error_message=message,
)
reservation = await load_reservation(session, task_id)
if reservation is not None:
await BillingService(session).refund(reservation, reason="report_export_failed")
await session.commit()
except Exception:
logger.exception("report_export_fail_settle_failed", task_id=task_id)
# Celery 的 task 装饰器没有类型标注,会把被装饰函数擦成 untyped
@celery_app.task(name="meida.report.export", bind=True, max_retries=1) # type: ignore[untyped-decorator]
def export_report_pdf(self: Any, task_id: int) -> dict[str, Any]:
"""cpu 队列的报告 PDF 导出入口。
重试语义与报告生成对齐:基础设施类异常重试一次;业务性拒绝
(ExportRejectedError)已在任务内结案,重跑也不会好,不再重试。
"""
bind_context(task_id=task_id)
try:
return cast("dict[str, Any]", worker_loop.run(_export_report(task_id)))
except ExportRejectedError as exc:
logger.warning("report_export_rejected", task_id=task_id, error=str(exc))
raise
except Exception as exc:
if self.request.retries < self.max_retries:
logger.warning("report_export_retry", task_id=task_id, error=str(exc))
raise self.retry(exc=exc, countdown=10) from exc
logger.exception("report_export_failed_after_retry", task_id=task_id)
worker_loop.run(_fail_export(task_id, f"{type(exc).__name__}: {exc}"))
raise