from functools import lru_cache from pathlib import Path from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): """Application settings sourced from environment / .env file.""" model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", extra="ignore", ) yunke_base_url: str = "https://crm2.yunkecn.com" yunke_username: str = "" yunke_password: str = "" db_url: str = "mysql+asyncmy://user:pass@localhost:3306/yunke" redis_url: str = "redis://localhost:6379/0" media_dir: Path = Path("./media") # Fernet key for encrypting stored credentials. Required for real runs; # empty default lets tests construct Settings without a key. encryption_key: str = "" # Existing-session creds (optional; bypasses programmatic login — e.g. for # validation with a live browser session). Read from .env / environment. yk_userid: str = "" yk_company: str = "" yk_cookie: str = "" # Shared bearer token for the viewing API. Empty (default) -> auth DISABLED # (we trust the network boundary; this is a local/intranet tool). When set, # every API request must present this value via ``Authorization: Bearer # `` or ``?token=``. See app/api/auth.py. api_token: str = "" # Username/password for the SPA login page (``POST /api/auth/login``). # On success the login hands out the ``api_token`` above so the SPA can # act as a Bearer client; defaults keep local dev zero-config. Change both # in prod via ADMIN_USER / ADMIN_PASSWORD. admin_user: str = "admin" admin_password: str = "admin123" # Comma-separated list of allowed CORS origins for the viewing API. Empty # (default) -> only the dev fallback (``http://localhost:5173``) is allowed; # set ``CORS_ORIGINS=https://app.example.com`` in prod to lock cross-origin # access down to explicit origins. Never ``*`` (wildcard + credentialed APIs # are an open door; we accept no credentials but still restrict origins to # avoid reflecting arbitrary attacker-controlled origins in ``Acces-Control- # Allow-Origin``). cors_origins: str = "" # --- Anti-risk-control / stealth tuning --------------------------------- # Tuned 2026-08-11 after triggering 风控 during a heavy session (~500+ # consecutive requests across groups/WA/voice-clips/SMS in ~1 hour). # The previous defaults (1.0s + 0.8s jitter = ~1.0..1.8s cadence) were # fast enough to trigger 云客's volume-based risk control when many # entities are synced back-to-back. New strategy: slower base cadence + # larger jitter (human-like irregularity) + hard per-run cap + longer # backoff with more retries (patience over aggression). # See app.session.yunke_client for the implementation. # Lower bound on seconds between successive upstream requests. # 2.0s base — roughly mimics a human clicking through pages (not a bot # hammering at 1 req/s). Combined with jitter this gives ~2.0..4.5s cadence. request_min_interval: float = 2.0 # Random extra 0..jitter seconds. Large jitter (2.5s) ensures the cadence # is highly irregular — no periodic signature for 云客 to fingerprint. request_jitter: float = 2.5 # Hard cap: 300 requests per run (~10 minutes at 2s avg). Prevents a # runaway sync from hammering 云客 for hours. 0 = unlimited (only for # manual debugging). A daily `sync --entity all` will likely need multiple # runs (or increase this cap) — by design, to stay under 风控 radar. max_requests_per_run: int = 300 # Exponential backoff: start at 5s, double each retry, cap at 120s. # More patient than the old 2s/60s/4 — gives 云客 time to cool down. risk_backoff_base: float = 5.0 risk_backoff_max: float = 120.0 # 6 retries (was 4) — more chances to recover before giving up. # Total worst-case backoff: 5+10+20+40+80+120 = 275s (~4.6 min) before # the final attempt; generous recovery window. risk_backoff_attempts: int = 6 @lru_cache def get_settings() -> Settings: """Return the process-wide Settings singleton.""" return Settings()