Files
meida_backend/packages/shared/config.py
T
xsl 9e7a886863 chore(prep): shared groundwork for the parallel task batch
多 agent 并行批次的协调提交(消除全部共享文件冲突面):
- 迁移 0004:id_session + ob_event 两表(T3/T4 用,任务不再写迁移)
- ORM:UserSession / ObEvent;错误码:九个新码(40xxx/60006,含 meta)
- 配置:四档 tiers / refresh TTL / 任务奖励注册表 / 埋点上限 / COS 直传全套
- 骨架:packages/analytics + packages/storage + events/uploads 空路由(已注册)
- deps:AnalyticsServiceDep / StorageServiceDep;DailyBudgetGuard 接收 tiers
- uv add cos-python-sdk-v5;import-linter/CI/Makefile 纳入两个新包
2026-08-14 16:02:54 +08:00

223 lines
8.6 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",
)
# --- 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",
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()