"""余额获取:模板渲染 → HTTP 请求(带重试)→ JSON 路径提取。""" from __future__ import annotations import logging import time from dataclasses import dataclass import requests from app.expr import extract_path, to_number from app.providers.base import BalanceProvider logger = logging.getLogger("monitor.fetcher") PLACEHOLDER = "{{apiKey}}" @dataclass class FetchResult: """一次拉取的结果。""" ok: bool balance: float | None = None auth_error: bool = False status_code: int | None = None error: str = "" def render_template(text: str, api_key: str) -> str: """把模板中的 {{apiKey}} 替换为账号密钥。""" return text.replace(PLACEHOLDER, api_key) def extract_balance(data: object, path: str) -> float: """按点路径/数组索引提取余额(兼容旧写法,返回 float)。 新写法请用 evaluate_balance(支持运算符与函数)。 """ return to_number(extract_path(data, path), path) def fetch_balance( provider: BalanceProvider, api_key: str, retry_count: int, timeout: int, ) -> FetchResult: """执行一次余额拉取。 策略: - 401/403 → 先按重试次数确认(可能瞬时),仍失败 → auth_error(调用方禁用账号并通知) - 其他 4xx → 直接失败(不重试) - 网络异常 / 5xx → 重试 retry_count 次,间隔 2s - JSON 解析/余额提取失败 → 直接失败 """ url, headers, body = provider.build_request(api_key) method = provider.method if method == "POST" and body and not any(k.lower() == "content-type" for k in headers): headers["Content-Type"] = "application/json" attempts = retry_count + 1 for attempt in range(attempts): t0 = time.time() try: if method == "GET": resp = requests.get(url, headers=headers, timeout=timeout) else: resp = requests.post(url, headers=headers, data=body, timeout=timeout) logger.debug("请求完成 %s -> %s(%.0fms)", url, resp.status_code, (time.time() - t0) * 1000) except requests.RequestException as exc: logger.warning("请求异常(%s/%s) %s: %s(%.0fms)", attempt + 1, attempts, url, exc, (time.time() - t0) * 1000) if attempt < attempts - 1: time.sleep(2) continue if resp.status_code in (401, 403): # 认证失败可能是瞬时的(key 刚生效/风控),先按重试次数再确认,仍失败才判定 if attempt < attempts - 1: logger.warning("认证失败(%s/%s) %s status=%s,重试确认", attempt + 1, attempts, url, resp.status_code) time.sleep(1) continue return FetchResult(ok=False, auth_error=True, status_code=resp.status_code, error=f"HTTP {resp.status_code}(认证失败)") if resp.status_code >= 500: logger.warning("服务端错误(%s/%s) %s status=%s", attempt + 1, attempts, url, resp.status_code) if attempt < attempts - 1: time.sleep(2) continue if resp.status_code >= 400: return FetchResult(ok=False, status_code=resp.status_code, error=f"HTTP {resp.status_code}: {resp.text[:120]}") try: data = resp.json() except ValueError: return FetchResult(ok=False, status_code=resp.status_code, error="响应不是合法 JSON") try: balance = provider.extract_balance(data) except Exception as exc: return FetchResult(ok=False, status_code=resp.status_code, error=str(exc)) return FetchResult(ok=True, balance=balance, status_code=resp.status_code) return FetchResult(ok=False, error=f"网络错误,重试 {retry_count} 次后仍失败")