Files

102 lines
3.9 KiB
Python
Raw Permalink 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.
"""余额获取:模板渲染 → 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} 次后仍失败")