Files
meida_backend/packages/shared/config.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

291 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Typed application configuration.
Every value comes from the environment (架构文档 §11.2: 密钥不进代码/镜像).
`.env` is only a developer convenience; dev/staging/prod inject via SSM/KMS.
"""
from __future__ import annotations
import json
from decimal import Decimal
from functools import lru_cache
from typing import Any, Literal
from pydantic import Field, ValidationInfo, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
Environment = Literal["local", "dev", "staging", "prod"]
CachePrefixMode = Literal["strict", "warn", "off"]
_PLACEHOLDER_JWT_SECRET = "change-me-local-only-do-not-use-outside-local"
class Settings(BaseSettings):
"""Root settings object. Read it through :func:`get_settings`."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
)
# --- Application -------------------------------------------------------
app_env: Environment = "local"
app_name: str = "meida-backend"
api_prefix: str = "/api/v1"
debug: bool = False
log_level: str = "INFO"
log_json: bool = True
log_extra_redact_keys: str = ""
# --- Database ----------------------------------------------------------
database_url: str = "postgresql+asyncpg://meida:meida@localhost:55432/meida"
db_pool_size: int = 10
db_max_overflow: int = 20
db_echo: bool = False
# --- Redis / Celery ----------------------------------------------------
redis_url: str = "redis://localhost:56379/0"
celery_broker_url: str = "redis://localhost:56379/1"
celery_result_backend: str = "redis://localhost:56379/2"
# --- Auth --------------------------------------------------------------
jwt_secret: str = _PLACEHOLDER_JWT_SECRET
jwt_algorithm: str = "HS256"
jwt_access_ttl_seconds: int = 900
jwt_issuer: str = "meida"
dev_auth_enabled: bool = True
# --- LLM routing -------------------------------------------------------
llm_primary_provider: str = "deepseek"
llm_backup_provider: str = "zhipu"
llm_structured_max_retries: int = 2
llm_circuit_failure_threshold: int = 3
llm_circuit_cooldown_seconds: float = 30.0
llm_scene_overrides: dict[str, dict[str, Any]] = Field(
default_factory=dict, alias="LLM_SCENE_OVERRIDES_JSON"
)
# --- DeepSeek (primary, OpenAI-compatible) -----------------------------
deepseek_api_key: str = ""
deepseek_base_url: str = "https://api.deepseek.com/v1"
deepseek_model: str = "deepseek-v4-flash"
deepseek_timeout_seconds: float = 30.0
# --- 智谱 GLM (backup slot, not implemented in V1) ---------------------
zhipu_api_key: str = ""
zhipu_base_url: str = "https://open.bigmodel.cn/api/paas/v4"
zhipu_model: str = "glm-4-flash"
zhipu_timeout_seconds: float = 30.0
# --- Prompt caching discipline (架构文档 §7.2) -------------------------
llm_cache_prefix_mode: CachePrefixMode = "strict"
prompts_dir: str = "prompts"
# --- Cost attribution (架构文档 §13) -----------------------------------
llm_cost_rates: dict[str, dict[str, Decimal]] = Field(
default_factory=dict, alias="LLM_COST_RATES_JSON"
)
# DeepSeek 自 2026-08-17 起峰谷定价,高峰单价是空闲时段的 2 倍
# (高峰:北京时间 09:00-12:00、14:00-18:00)。留空则两个时段同价。
llm_cost_rates_peak: dict[str, dict[str, Decimal]] = Field(
default_factory=dict, alias="LLM_COST_RATES_PEAK_JSON"
)
llm_peak_pricing_enabled: bool = True
llm_default_cost_rate: dict[str, Decimal] = Field(
default_factory=lambda: {
"input_cents_per_mtok": Decimal("100"),
"cached_input_cents_per_mtok": Decimal("20"),
"output_cents_per_mtok": Decimal("200"),
},
alias="LLM_DEFAULT_COST_RATE_JSON",
)
# --- Global daily cost circuit breaker ---------------------------------
llm_daily_budget_enabled: bool = True
llm_daily_budget_cents: Decimal = Decimal("100000")
llm_budget_timezone_offset_hours: int = 8
# 四档分级降级(架构 §13.3,T-P1-25b)。JSON 为列表:
# [{"threshold":0.8,"name":"degrade_secondary","blocked":["hd_export"],...}]
# blocked 支持 "*"; allow 仅 block_all 档有意义(纯文本对话放行)。
llm_budget_tiers: list[dict[str, Any]] = Field(
default_factory=lambda: [
{"threshold": 0.6, "name": "warn", "blocked": []},
{
"threshold": 0.8,
"name": "degrade_secondary",
"blocked": ["hd_export", "report_regen"],
},
{"threshold": 0.9, "name": "degrade_generation", "blocked": ["hairstyle", "tryon"]},
{"threshold": 1.0, "name": "block_all", "blocked": "*", "allow": ["agent_chat"]},
],
alias="LLM_BUDGET_TIERS_JSON",
)
# --- Auth: refresh sessions (FR-ACC-05) --------------------------------
refresh_token_ttl_seconds: int = 2_592_000 # 30 天
# --- 任务奖励(FR-BILL-03/04,配置化)-----------------------------------
# amount 全部是占位值 1:正式数值待产品在产品方案 §3.2 定稿后改 env,不改代码。
# claim_period 决定幂等粒度(同一任务同周期只能领一次)。
task_bonus_rules: dict[str, dict[str, Any]] = Field(
default_factory=lambda: {
"sign_in": {"quota_key": "agent_chat", "amount": 1, "claim_period": "day"},
"profile_complete": {
"quota_key": "agent_chat",
"amount": 1,
"claim_period": "lifetime",
},
"report_feedback": {"quota_key": "report_regen", "amount": 1, "claim_period": "day"},
"share_report": {"quota_key": "tryon", "amount": 1, "claim_period": "day"},
"invite_friend": {"quota_key": "tryon", "amount": 1, "claim_period": "day"},
},
alias="TASK_BONUS_RULES_JSON",
)
# --- 客户端埋点(FR-SYS-06)---------------------------------------------
events_max_batch: int = 50
events_props_max_bytes: int = 8192
# --- 腾讯云 COS 直传(FR-SYS-03 / T-P1-27)------------------------------
cos_region: str = "ap-shanghai"
cos_secret_id: str = ""
cos_secret_key: str = ""
cos_bucket_temp: str = "meida-temp" # 临时桶,2 小时生命周期(CR-02)
cos_bucket_user: str = "meida-user"
cos_bucket_kb: str = "meida-kb"
cos_bucket_report: str = "meida-report"
cos_presign_expire_seconds: int = 600
upload_max_bytes: int = 20_971_520 # 20MB
upload_allowed_types: str = "image/jpeg,image/png,image/webp"
# scene → {"bucket": "temp"|"user"|"kb"|"report"}。新增上传场景只改这里。
upload_scenes: dict[str, dict[str, Any]] = Field(
default_factory=lambda: {
"report_photo": {"bucket": "temp"},
"avatar": {"bucket": "temp"},
},
alias="UPLOAD_SCENES_JSON",
)
# --- 知识库与检索(架构 §8.3,TD-08)--------------------------------------
kb_vector_dimensions: int = 1024
# 当前默认 = 确定性假实现;真实 BGE-M3 ONNX 是后续任务,届时只换这里。
kb_embedding_model: str = "deterministic-v1"
kb_embedding_device: str = "cpu"
retrieval_top_k: int = 50
# 重排权重(架构 §8.3 唯一口径):0.45 向量 + 0.2 关键词 + 0.25 规则 + 0.1 新鲜度。
retrieval_rerank_weights: dict[str, float] = Field(
default_factory=lambda: {"vector": 0.45, "keyword": 0.2, "rule": 0.25, "freshness": 0.1},
alias="RETRIEVAL_RERANK_WEIGHTS_JSON",
)
# --- 华为账号登录(FR-ACC-01;联调待 AGC 配置,见《前置开通清单》§3)------
huawei_jwks_url: str = "https://oauth-login.cloud.huawei.com/oauth2/v3/certs"
huawei_audience: str = "" # = AGC App ID;空配置时验签必须拒绝而不是跳过
huawei_issuer: str = "https://accounts.huawei.com"
huawei_jwks_cache_seconds: int = 86400
# --- 手机号验证码登录(FR-ACC-04/14,TD-32)--------------------------------
# fake = 验证码打日志(dev);tencent = 真实发送(待签名审核通过后切换)
sms_provider: str = "fake"
tencent_sms_secret_id: str = ""
tencent_sms_secret_key: str = ""
tencent_sms_sdk_app_id: str = ""
tencent_sms_sign_name: str = ""
tencent_sms_template_id: str = ""
sms_code_ttl_seconds: int = 300
sms_code_length: int = 6
sms_verify_max_attempts: int = 5
sms_resend_cooldown_seconds: int = 60
sms_hourly_limit: int = 5
sms_daily_limit: int = 10
sms_ip_daily_limit: int = 20
sms_device_daily_limit: int = 10
# --- 极简运营后台(T-P2-09)----------------------------------------------
# 空字符串 = 后台全部端点 404(等价于未部署)。V1 极简鉴权:共享令牌,
# 后续接运营账号体系时替换。令牌只进 .env / SSM,绝不进代码。
admin_token: str = ""
# --- 检索结果缓存(T-P2-10,T14 实现背后的配置)--------------------------
retrieval_cache_enabled: bool = True
retrieval_cache_ttl_seconds: int = 60
retrieval_cache_max_entries: int = 512
# --- 美学报告流水线(T-P3,第四批)---------------------------------------
# 阶段按 dotted path 动态装配(importlib):T15 编排、T16 供 vision/diagnosis、
# T17 供 copy/validation——三个任务互不改对方文件,靠这份清单衔接。
# 缺失模块/类在装配时报 CONFIG_ERROR 并列出缺失项,绝不静默跳过。
report_pipeline_stages: list[str] = Field(
default_factory=lambda: [
"packages.report.vision_stage:VisionStage",
"packages.report.diagnosis_stage:DiagnosisStage",
"packages.report.copy_stage:CopyStage",
"packages.report.validation_stage:ValidationStage",
],
alias="REPORT_PIPELINE_STAGES_JSON",
)
# 视觉提供者:deterministic = V1 骨架的确定性假实现(真实 RTMPose/BiRefNet
# 是 Sprint 1 结论落地后的替换项,接口位见 packages/report/vision.py)。
report_vision_provider: str = "deterministic"
report_tier: str = "basic"
# PDF 导出落盘目录(相对进程 cwd;worker 与 API 同 cwd 启动,两边解析一致)。
# rp_report.rendered_asset_key 存的是含该目录前缀的相对路径。
report_export_dir: str = "var/exports"
# --- Observability -----------------------------------------------------
otel_enabled: bool = False
otel_service_name: str = "meida-api"
otel_exporter_otlp_endpoint: str = ""
prometheus_enabled: bool = True
@field_validator(
"llm_scene_overrides",
"llm_cost_rates",
"llm_cost_rates_peak",
"llm_default_cost_rate",
"llm_budget_tiers",
"task_bonus_rules",
"upload_scenes",
"retrieval_rerank_weights",
"report_pipeline_stages",
mode="before",
)
@classmethod
def _parse_json_object(cls, value: Any) -> Any:
if isinstance(value, str):
stripped = value.strip()
if not stripped:
return {}
return json.loads(stripped)
return value
@field_validator("jwt_secret")
@classmethod
def _reject_placeholder_secret_outside_local(cls, value: str, info: ValidationInfo) -> str:
env = info.data.get("app_env", "local")
if env in ("staging", "prod") and (value == _PLACEHOLDER_JWT_SECRET or len(value) < 32):
msg = "JWT_SECRET must be set to a >=32 char random value outside local/dev"
raise ValueError(msg)
return value
@property
def is_local(self) -> bool:
return self.app_env in ("local", "dev")
@property
def extra_redact_keys(self) -> tuple[str, ...]:
return tuple(k.strip() for k in self.log_extra_redact_keys.split(",") if k.strip())
@property
def sync_database_url(self) -> str:
"""psycopg-style URL, used by tooling that cannot speak asyncpg."""
return self.database_url.replace("+asyncpg", "")
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""Process-wide settings singleton."""
return Settings()