Files
meida_backend/apps/api/deps.py
T
xsl cd0ddb131b 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 造数
2026-08-30 13:17:23 +08:00

224 lines
7.8 KiB
Python

"""Composition root + FastAPI dependencies.
This is the only place that wires concrete adapters into the domain packages.
Everything below it takes its collaborators as constructor arguments, which is
what keeps ``packages/domain`` and ``packages/llm`` unit-testable without IO.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from decimal import Decimal
from typing import Annotated
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from packages.agent.service import AgentService
from packages.analytics.service import AnalyticsService
from packages.billing.service import BillingService
from packages.identity.service import IdentityService
from packages.llm.budget import DailyBudgetGuard, RedisCostCounter
from packages.llm.circuit import CircuitBreakerRegistry
from packages.llm.cost import (
CompositeAICallRecorder,
ModelRate,
PeakAwareRateTable,
RateTable,
)
from packages.llm.gateway import LLMGateway
from packages.llm.prompts import PromptRegistry
from packages.llm.providers.base import LLMProvider
from packages.llm.providers.deepseek import DeepSeekProvider
from packages.llm.providers.zhipu import ZhipuGLMProvider
from packages.llm.recording import PrometheusAICallRecorder, SqlAlchemyAICallRecorder
from packages.llm.routing import SceneRouter, build_scene_configs
from packages.shared.config import Settings, 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.redis_client import get_redis
from packages.shared.security import decode_access_token
from packages.storage.service import StorageService
from packages.task.service import TaskService
bearer_scheme = HTTPBearer(auto_error=False)
def build_llm_gateway(settings: Settings) -> LLMGateway:
"""Assemble the gateway from configuration (架构文档 §8.1)."""
providers: dict[str, LLMProvider] = {
"deepseek": DeepSeekProvider(
api_key=settings.deepseek_api_key,
base_url=settings.deepseek_base_url,
default_model=settings.deepseek_model,
),
"zhipu": ZhipuGLMProvider(
api_key=settings.zhipu_api_key,
base_url=settings.zhipu_base_url,
default_model=settings.zhipu_model,
),
}
router = SceneRouter(
build_scene_configs(
primary_provider=settings.llm_primary_provider,
backup_provider=settings.llm_backup_provider,
structured_max_retries=settings.llm_structured_max_retries,
overrides=settings.llm_scene_overrides,
)
)
default_rate = ModelRate.from_mapping(settings.llm_default_cost_rate)
rates = PeakAwareRateTable(
RateTable(
{
model: ModelRate.from_mapping(rate)
for model, rate in settings.llm_cost_rates.items()
},
default=default_rate,
),
RateTable(
{
model: ModelRate.from_mapping(rate)
for model, rate in settings.llm_cost_rates_peak.items()
},
default=default_rate,
)
if settings.llm_cost_rates_peak
else None,
enabled=settings.llm_peak_pricing_enabled,
)
recorder = CompositeAICallRecorder(
SqlAlchemyAICallRecorder(get_session_factory()),
PrometheusAICallRecorder(),
)
budget = 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),
)
return LLMGateway(
providers=providers,
router=router,
prompts=PromptRegistry(
settings.prompts_dir, cache_prefix_mode=settings.llm_cache_prefix_mode
),
rates=rates,
recorder=recorder,
budget=budget,
breakers=CircuitBreakerRegistry(
failure_threshold=settings.llm_circuit_failure_threshold,
cooldown_seconds=settings.llm_circuit_cooldown_seconds,
),
)
# --- request-scoped dependencies -------------------------------------------
def settings_dep() -> Settings:
return get_settings()
async def session_dep() -> AsyncIterator[AsyncSession]:
factory = get_session_factory()
async with factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
def gateway_dep(request: Request) -> LLMGateway:
# app.state 是无类型的属性袋,取出来必须显式收窄
gateway: LLMGateway | None = getattr(request.app.state, "llm_gateway", None)
if gateway is None: # pragma: no cover - startup guarantees this
raise AppError(ErrorCode.CONFIG_ERROR, message="LLM 网关未初始化")
return gateway
SettingsDep = Annotated[Settings, Depends(settings_dep)]
SessionDep = Annotated[AsyncSession, Depends(session_dep)]
GatewayDep = Annotated[LLMGateway, Depends(gateway_dep)]
async def current_user_id(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)],
settings: SettingsDep,
) -> int:
"""架构文档 §9.1: ``Authorization: Bearer <access_token>``."""
if credentials is None or not credentials.credentials:
raise AppError(ErrorCode.UNAUTHENTICATED)
claims = decode_access_token(
credentials.credentials,
secret=settings.jwt_secret,
algorithm=settings.jwt_algorithm,
issuer=settings.jwt_issuer,
)
bind_context(user_id=claims.user_id)
return claims.user_id
CurrentUserId = Annotated[int, Depends(current_user_id)]
def identity_service_dep(session: SessionDep) -> IdentityService:
return IdentityService(session)
def billing_service_dep(session: SessionDep, settings: SettingsDep) -> BillingService:
return BillingService(session, tz_offset_hours=settings.llm_budget_timezone_offset_hours)
def task_service_dep(session: SessionDep) -> TaskService:
return TaskService(session)
def analytics_service_dep(session: SessionDep) -> AnalyticsService:
return AnalyticsService(session)
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,
billing: Annotated[BillingService, Depends(billing_service_dep)],
) -> AgentService:
return AgentService(session, gateway=gateway, billing=billing)
IdentityServiceDep = Annotated[IdentityService, Depends(identity_service_dep)]
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)]