"""全局配置。 用 pydantic-settings 从环境变量 + .env 文件加载。所有可调参数集中在这里, 业务代码通过 `from app.core.config import settings` 引用,不再读 os.environ。 """ from __future__ import annotations from functools import lru_cache from pathlib import Path from typing import Literal from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict _PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=str(_PROJECT_ROOT / ".env"), env_file_encoding="utf-8", case_sensitive=False, extra="ignore", ) # ===== 环境 ===== APP_ENV: Literal["dev", "prod"] = "dev" APP_NAME: str = "shaguabijia-app-server" APP_DEBUG: bool = True # 接口限流开关(测试里关掉,避免内存计数跨用例累加误伤) RATE_LIMIT_ENABLED: bool = True # ===== 数据库 ===== DATABASE_URL: str = "sqlite:///./data/app.db" # ===== JWT ===== JWT_SECRET_KEY: str = "change-me" JWT_ALGORITHM: str = "HS256" JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 120 JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30 # ===== Admin 后台 ===== # admin 用独立 JWT secret(≠ JWT_SECRET_KEY),App 用户 token 无法越权访问后台。 # 生产必须改成高熵随机串(同 JWT_SECRET_KEY 的要求)。 ADMIN_JWT_SECRET: str = "change-me-admin" ADMIN_JWT_EXPIRE_MINUTES: int = 720 # admin 登录态 12 小时(无 refresh,过期重登) # 可选 IP 白名单(逗号分隔),为空=应用层不限制(靠 nginx allow/deny 兜底)。 ADMIN_IP_ALLOWLIST: str = "" @property def admin_ip_allowlist(self) -> list[str]: if not self.ADMIN_IP_ALLOWLIST.strip(): return [] return [ip.strip() for ip in self.ADMIN_IP_ALLOWLIST.split(",") if ip.strip()] # ===== 极光 ===== JG_APP_KEY: str = "" JG_MASTER_SECRET: str = "" JG_PRIVATE_KEY_PATH: str = "./secrets/jverify_rsa_private.pem" JG_VERIFY_ENDPOINT: str = "https://api.verification.jpush.cn/v1/web/loginTokenVerify" JG_REQUEST_TIMEOUT_SEC: int = 15 # ===== 短信 ===== SMS_MOCK: bool = True SMS_CODE_TTL_SEC: int = 300 SMS_SEND_INTERVAL_SEC: int = 60 # 真实发送走极光短信 REST(自定义验证码模式:本服务生成 code,极光只负责发)。 # 复用极光一键登录的 JG_APP_KEY / JG_MASTER_SECRET(同一个极光应用)+ JG_REQUEST_TIMEOUT_SEC。 SMS_SEND_ENDPOINT: str = "https://api.sms.jpush.cn/v1/messages" SMS_SIGN_ID: int = 31729 # 极光短信签名 ID(非机密,可被 .env 覆盖) SMS_TEMPLATE_ID: int = 1 # 极光短信模板 ID(变量名 code,有效期 5 分钟) SMS_CODE_LENGTH: int = 6 # 验证码位数(本服务生成;前端 code 字段 4-8 位兼容) SMS_DAILY_LIMIT_PER_PHONE: int = 10 # 单手机号每日发送上限(防刷 + 控费) SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破) # ===== 美团联盟 CPS ===== # 未配置时所有 /api/v1/meituan/* 接口 200 返空(优雅降级),不影响登录/领券等其他业务。 MT_CPS_APP_KEY: str = "" MT_CPS_APP_SECRET: str = "" MT_CPS_HOST: str = "https://media.meituan.com" MT_CPS_TIMEOUT_SEC: int = 15 MT_CPS_DEFAULT_SID: str = "sgbjia" @property def mt_cps_configured(self) -> bool: """美团 CPS 凭证齐全(缺则接口返空,而非 502)。""" return bool(self.MT_CPS_APP_KEY and self.MT_CPS_APP_SECRET) # ===== 微信支付(商家转账到零钱 / 提现)===== # 真实凭证放 .env(已 gitignore),证书 .pem 放 secrets/。WECHAT_APP_ID 同时用于 # 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。 WECHAT_APP_ID: str = "" # 开放平台移动应用 appid(wx 开头) WECHAT_APP_SECRET: str = "" # 开放平台 AppSecret,code 换 openid 用 WXPAY_MCH_ID: str = "" # 微信支付商户号 WXPAY_MCH_SERIAL_NO: str = "" # 商户 API 证书序列号 WXPAY_MCH_PRIVATE_KEY_PATH: str = "./secrets/apiclient_key.pem" # 商户 API 私钥 WXPAY_PUBLIC_KEY_ID: str = "" # 微信支付平台公钥 ID(PUB_KEY_ID_...) WXPAY_PUBLIC_KEY_PATH: str = "./secrets/pub_key.pem" # 微信支付平台公钥 WXPAY_TRANSFER_SCENE_ID: str = "1000" # 转账场景 ID(1000=现金营销) WXPAY_REQUEST_TIMEOUT_SEC: int = 10 @property def wxpay_configured(self) -> bool: """凭证是否齐全(缺则提现接口拒绝调用,而非启动时炸)。""" return bool( self.WECHAT_APP_ID and self.WXPAY_MCH_ID and self.WXPAY_MCH_SERIAL_NO and self.WXPAY_PUBLIC_KEY_ID ) # ===== 穿山甲激励视频(服务端发奖回调)===== # 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。 # PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",验签用,从后台取到后填 .env。 PANGLE_CALLBACK_ENABLED: bool = False PANGLE_REWARD_SECRET: str = "" # ⚠️ 仅本地联调:打开后开放 POST /api/v1/ad/test-grant,让(已登录的)客户端在没部署公网、 # 穿山甲 S2S 回调打不到本地时,直接触发一次发奖,验证"看广告→金币到账"全链路。 # 它让客户端能自助发奖 = 绕过反作弊,**生产必须保持 False**(默认 False;只在本地 .env 设 true)。 AD_REWARD_TEST_GRANT_ENABLED: bool = False @property def pangle_callback_configured(self) -> bool: """回调开关打开且验签密钥已配,才接受发奖回调。""" return bool(self.PANGLE_CALLBACK_ENABLED and self.PANGLE_REWARD_SECRET) # ===== Pricebot 上游 (领券业务透传目标) ===== # pricebot-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step PRICEBOT_BASE_URL: str = "http://localhost:8000" # 领券一帧最多 wait 6s,加网络往返,timeout 给 30s 比较稳 PRICEBOT_REQUEST_TIMEOUT_SEC: int = 30 # 比价(intent/recognize + price/step)透传超时:意图识别是大上下文 LLM、 # price/step 每帧也是 LLM,可能 >30s;对齐客户端 agent ApiClient 的 60s 读超时。 PRICEBOT_COMPARE_TIMEOUT_SEC: int = 60 # ===== 媒体文件(用户头像上传)===== # 落盘根目录(data/ 已 gitignore,上传不进库);对外经 StaticFiles 挂在 MEDIA_URL_PREFIX。 # 生产可改由 nginx 直接 serve MEDIA_ROOT,绕过应用进程。 MEDIA_ROOT: str = "./data/media" MEDIA_URL_PREFIX: str = "/media" AVATAR_MAX_BYTES: int = 5 * 1024 * 1024 # 头像最大 5MB # ===== CORS ===== CORS_ALLOW_ORIGINS: str = "" @property def cors_origins_list(self) -> list[str]: if not self.CORS_ALLOW_ORIGINS.strip(): return [] return [o.strip() for o in self.CORS_ALLOW_ORIGINS.split(",") if o.strip()] @property def is_prod(self) -> bool: return self.APP_ENV == "prod" @lru_cache(maxsize=1) def get_settings() -> Settings: return Settings() settings = get_settings()