refactor: 平台改为代码内置 provider 架构,一次性迁移现有数据 - 新增 app/providers:BalanceProvider 基类 + DeepSeek 适配器 + 注册表 - platforms 表去除 method/url/headers/body/balance_path,新增 provider_id - 现有数据库已一次性迁移(备份 data/monitor.db.bak-v1),不保留迁移工具 - 平台 UI 改为选择内置提供方;fetcher/monitor 走 provider 构建请求与解析 - 表达式引擎(运算符/函数)随架构保留,供 provider 内部使用 - 测试更新至 provider 模式,共 92 个
This commit is contained in:
+32
-28
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import sqlite3
|
||||
@@ -27,6 +26,7 @@ from app.models import (
|
||||
SettingsUpdate,
|
||||
)
|
||||
from app.monitor import Monitor
|
||||
from app.providers import get_provider, list_providers as list_providers_svc
|
||||
|
||||
logger = logging.getLogger("monitor.api")
|
||||
|
||||
@@ -130,36 +130,40 @@ def create_app(cfg: Config | None = None) -> FastAPI:
|
||||
|
||||
# ---------- 平台 ----------
|
||||
|
||||
@app.get("/api/providers", dependencies=[Depends(require_auth)])
|
||||
def list_providers():
|
||||
"""代码内置的平台适配器列表(新增平台 = 代码扩展)。"""
|
||||
return list_providers_svc()
|
||||
|
||||
@app.get("/api/platforms", dependencies=[Depends(require_auth)])
|
||||
def list_platforms():
|
||||
with db.get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT p.*, (SELECT COUNT(*) FROM accounts a WHERE a.platform_id = p.id) AS account_count
|
||||
"""SELECT p.id, p.name, p.currency, p.icon, p.provider_id,
|
||||
p.interval_seconds, p.retry_count, p.timeout_seconds,
|
||||
p.enabled, p.note, p.created_at, p.updated_at,
|
||||
(SELECT COUNT(*) FROM accounts a WHERE a.platform_id = p.id) AS account_count
|
||||
FROM platforms p ORDER BY p.id"""
|
||||
).fetchall()
|
||||
result = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
try:
|
||||
d["headers"] = json.loads(d["headers"]) if d["headers"] else {}
|
||||
except (ValueError, TypeError):
|
||||
d["headers"] = {}
|
||||
result.append(d)
|
||||
return result
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@app.post("/api/platforms", dependencies=[Depends(require_auth)])
|
||||
def create_platform(body: PlatformCreate):
|
||||
provider = get_provider(body.provider_id)
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的平台提供方: {body.provider_id}")
|
||||
currency = body.currency or provider.currency
|
||||
icon = body.icon or provider.icon
|
||||
try:
|
||||
with db.get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO platforms (name, currency, icon, method, url, headers, body,
|
||||
balance_path, interval_seconds, retry_count, timeout_seconds, enabled, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
"""INSERT INTO platforms (provider_id, name, currency, icon,
|
||||
interval_seconds, retry_count, timeout_seconds, enabled, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
body.name, body.currency, body.icon, body.method, body.url,
|
||||
__import__("json").dumps(body.headers, ensure_ascii=False),
|
||||
body.body, body.balance_path, body.interval_seconds,
|
||||
body.retry_count, body.timeout_seconds, int(body.enabled), body.note,
|
||||
body.provider_id, body.name, currency, icon,
|
||||
body.interval_seconds, body.retry_count, body.timeout_seconds,
|
||||
int(body.enabled), body.note,
|
||||
),
|
||||
)
|
||||
pid = cur.lastrowid
|
||||
@@ -172,22 +176,22 @@ def create_app(cfg: Config | None = None) -> FastAPI:
|
||||
@app.put("/api/platforms/{pid}", dependencies=[Depends(require_auth)])
|
||||
def update_platform(pid: int, body: PlatformUpdate):
|
||||
fields = {}
|
||||
if body.provider_id is not None:
|
||||
provider = get_provider(body.provider_id)
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的平台提供方: {body.provider_id}")
|
||||
fields["provider_id"] = body.provider_id
|
||||
# 换 provider 时若未显式给货币/图标,则重置为 provider 默认
|
||||
if "currency" not in body.model_fields_set:
|
||||
fields["currency"] = provider.currency
|
||||
if "icon" not in body.model_fields_set:
|
||||
fields["icon"] = provider.icon
|
||||
if body.name is not None:
|
||||
fields["name"] = body.name
|
||||
if body.currency is not None:
|
||||
fields["currency"] = body.currency
|
||||
if body.icon is not None:
|
||||
fields["icon"] = body.icon
|
||||
if body.method is not None:
|
||||
fields["method"] = body.method
|
||||
if body.url is not None:
|
||||
fields["url"] = body.url
|
||||
if body.headers is not None:
|
||||
fields["headers"] = __import__("json").dumps(body.headers, ensure_ascii=False)
|
||||
if body.body is not None:
|
||||
fields["body"] = body.body
|
||||
if body.balance_path is not None:
|
||||
fields["balance_path"] = body.balance_path
|
||||
if body.interval_seconds is not None:
|
||||
fields["interval_seconds"] = body.interval_seconds
|
||||
if body.retry_count is not None:
|
||||
|
||||
@@ -11,14 +11,10 @@ DB_PATH = DATA_DIR / "monitor.db"
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS platforms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider_id TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
currency TEXT NOT NULL DEFAULT 'USD',
|
||||
icon TEXT NOT NULL DEFAULT '',
|
||||
method TEXT NOT NULL DEFAULT 'GET',
|
||||
url TEXT NOT NULL,
|
||||
headers TEXT NOT NULL DEFAULT '{}',
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
balance_path TEXT NOT NULL,
|
||||
interval_seconds INTEGER,
|
||||
retry_count INTEGER,
|
||||
timeout_seconds INTEGER,
|
||||
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
"""受限的余额表达式引擎。
|
||||
|
||||
在 JSON 提取路径基础上支持:
|
||||
- 运算符:+ - * / // % ** 一元负号、括号
|
||||
- 函数(白名单):float / int / abs / round / min / max / sum / len
|
||||
- 路径写法与原来完全兼容:data.balance、data[0].x、$.data[0].x
|
||||
|
||||
示例:
|
||||
data.balance / 100 # 分转元
|
||||
float(data.balance) # 字符串转数字
|
||||
data.granted + data.topped_up # 多字段求和
|
||||
sum(data[0].balances) # 数组求和
|
||||
round(data.balance, 2) * 0.9
|
||||
|
||||
安全性:无 eval、无任意变量访问,函数名严格白名单,路径只支持
|
||||
dict 键 / list 索引访问。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_PATH_SEG_RE = re.compile(r"([^.\[]+)|\[(-?\d+)\]")
|
||||
|
||||
|
||||
class ExprError(ValueError):
|
||||
"""表达式语法错误 / 求值错误。"""
|
||||
|
||||
|
||||
# ---------- 路径提取(返回原始值,不强制数字) ----------
|
||||
|
||||
def extract_path(data: object, path: str) -> object:
|
||||
p = path.strip()
|
||||
if p.startswith("$"):
|
||||
p = p[1:]
|
||||
cur: object = data
|
||||
for key, idx in _PATH_SEG_RE.findall(p):
|
||||
if key:
|
||||
if not isinstance(cur, dict) or key not in cur:
|
||||
raise ExprError(f"路径不存在: {path}(在 {key!r} 处)")
|
||||
cur = cur[key]
|
||||
if idx != "":
|
||||
n = int(idx)
|
||||
if not isinstance(cur, list) or n >= len(cur):
|
||||
raise ExprError(f"数组索引越界: {path}(索引 {n})")
|
||||
cur = cur[n]
|
||||
return cur
|
||||
|
||||
|
||||
def to_number(value: object, src: str) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
||||
raise ExprError(f"余额不是数字: {src} -> {value!r}")
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ExprError(f"余额不是数字: {src} -> {value!r}")
|
||||
|
||||
|
||||
# ---------- 词法 ----------
|
||||
|
||||
_TOKEN_RE = re.compile(r"""
|
||||
(?P<num>\d+(?:\.\d+)?)
|
||||
| (?P<path>\$?\.?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*|\[-?\d+\])*)
|
||||
| (?P<op>//|\*\*|[+\-*/%(),])
|
||||
| (?P<ws>\s+)
|
||||
""", re.VERBOSE)
|
||||
|
||||
|
||||
def _tokenize(text: str) -> list[tuple[str, str]]:
|
||||
tokens: list[tuple[str, str]] = []
|
||||
pos = 0
|
||||
for m in _TOKEN_RE.finditer(text):
|
||||
if m.start() != pos:
|
||||
raise ExprError(f"表达式语法错误(第 {pos + 1} 字符附近): {text!r}")
|
||||
pos = m.end()
|
||||
kind = m.lastgroup
|
||||
if kind == "ws":
|
||||
continue
|
||||
val = m.group()
|
||||
if kind == "path" and text[m.end():m.end() + 1] == "(" and "." not in val and "[" not in val:
|
||||
tokens.append(("FUNC", val))
|
||||
else:
|
||||
tokens.append((kind.upper(), val))
|
||||
if pos != len(text):
|
||||
raise ExprError(f"表达式语法错误(第 {pos + 1} 字符附近): {text!r}")
|
||||
tokens.append(("EOF", ""))
|
||||
return tokens
|
||||
|
||||
|
||||
# ---------- 函数白名单 ----------
|
||||
|
||||
def _call(name: str, args: list) -> object:
|
||||
if name == "float":
|
||||
_need_arity(name, args, 1)
|
||||
return to_number(args[0], name)
|
||||
if name == "int":
|
||||
_need_arity(name, args, 1)
|
||||
return int(to_number(args[0], name))
|
||||
if name == "abs":
|
||||
_need_arity(name, args, 1)
|
||||
return abs(to_number(args[0], name))
|
||||
if name == "round":
|
||||
if len(args) == 1:
|
||||
return round(to_number(args[0], name))
|
||||
if len(args) == 2:
|
||||
return round(to_number(args[0], name), int(args[1]))
|
||||
raise ExprError("round() 需要 1 或 2 个参数")
|
||||
if name == "len":
|
||||
_need_arity(name, args, 1)
|
||||
if not isinstance(args[0], (list, dict, str)):
|
||||
raise ExprError("len() 参数必须是数组/对象/字符串")
|
||||
return len(args[0])
|
||||
if name == "sum":
|
||||
if len(args) == 1 and isinstance(args[0], list):
|
||||
return sum(to_number(v, name) for v in args[0])
|
||||
if not args:
|
||||
raise ExprError("sum() 至少需要 1 个参数")
|
||||
return sum(to_number(v, name) for v in args)
|
||||
if name == "min":
|
||||
values = args[0] if len(args) == 1 and isinstance(args[0], list) else args
|
||||
if not values:
|
||||
raise ExprError("min() 参数不能为空")
|
||||
return min(to_number(v, name) for v in values)
|
||||
if name == "max":
|
||||
values = args[0] if len(args) == 1 and isinstance(args[0], list) else args
|
||||
if not values:
|
||||
raise ExprError("max() 参数不能为空")
|
||||
return max(to_number(v, name) for v in values)
|
||||
raise ExprError(f"不支持的函数: {name}()(可用: float/int/abs/round/min/max/sum/len)")
|
||||
|
||||
|
||||
def _need_arity(name: str, args: list, n: int) -> None:
|
||||
if len(args) != n:
|
||||
raise ExprError(f"{name}() 需要 {n} 个参数,实际 {len(args)} 个")
|
||||
|
||||
|
||||
# ---------- 语法分析 + 求值(递归下降,直接求值) ----------
|
||||
|
||||
class _Evaluator:
|
||||
def __init__(self, data: object, text: str) -> None:
|
||||
self.data = data
|
||||
self.tokens = _tokenize(text)
|
||||
self.pos = 0
|
||||
|
||||
def _peek(self) -> tuple[str, str]:
|
||||
return self.tokens[self.pos]
|
||||
|
||||
def _next(self) -> tuple[str, str]:
|
||||
tok = self.tokens[self.pos]
|
||||
self.pos += 1
|
||||
return tok
|
||||
|
||||
def _accept_op(self, *ops: str) -> tuple[str, str] | None:
|
||||
kind, val = self._peek()
|
||||
if kind == "OP" and val in ops:
|
||||
return self._next()
|
||||
return None
|
||||
|
||||
def _expect_op(self, op: str) -> None:
|
||||
kind, val = self._next()
|
||||
if kind != "OP" or val != op:
|
||||
raise ExprError(f"期望 {op!r},实际 {val!r}")
|
||||
|
||||
def evaluate(self) -> float:
|
||||
value = self._expr()
|
||||
if self._peek() != ("EOF", ""):
|
||||
raise ExprError(f"表达式多余内容: {self._peek()!r}")
|
||||
return to_number(value, "表达式")
|
||||
|
||||
def _expr(self) -> object:
|
||||
v = self._term()
|
||||
while (op := self._accept_op("+", "-")) is not None:
|
||||
rhs = self._term()
|
||||
v = v + rhs if op[1] == "+" else v - rhs
|
||||
return v
|
||||
|
||||
def _term(self) -> object:
|
||||
v = self._factor()
|
||||
while (op := self._accept_op("*", "/", "//", "%")) is not None:
|
||||
rhs = self._factor()
|
||||
if op[1] == "*":
|
||||
v = v * rhs
|
||||
elif op[1] == "/":
|
||||
v = v / rhs
|
||||
elif op[1] == "//":
|
||||
v = v // rhs
|
||||
else:
|
||||
v = v % rhs
|
||||
return v
|
||||
|
||||
def _factor(self) -> object:
|
||||
v = self._unary()
|
||||
if self._accept_op("**") is not None:
|
||||
rhs = self._factor() # 右结合
|
||||
v = v ** rhs
|
||||
return v
|
||||
|
||||
def _unary(self) -> object:
|
||||
if (op := self._accept_op("-", "+")) is not None:
|
||||
v = self._unary()
|
||||
return -v if op[1] == "-" else v
|
||||
return self._primary()
|
||||
|
||||
def _primary(self) -> object:
|
||||
kind, val = self._peek()
|
||||
if kind == "NUM":
|
||||
self._next()
|
||||
return float(val) if "." in val else int(val)
|
||||
if kind == "PATH":
|
||||
self._next()
|
||||
return extract_path(self.data, val)
|
||||
if kind == "FUNC":
|
||||
return self._call()
|
||||
if kind == "OP" and val == "(":
|
||||
self._next()
|
||||
v = self._expr()
|
||||
self._expect_op(")")
|
||||
return v
|
||||
raise ExprError(f"表达式语法错误: 意外的 {kind} {val!r}")
|
||||
|
||||
def _call(self) -> object:
|
||||
name = self._next()[1]
|
||||
self._expect_op("(")
|
||||
args: list = []
|
||||
if not (self._peek()[0] == "OP" and self._peek()[1] == ")"):
|
||||
while True:
|
||||
args.append(self._expr())
|
||||
if self._accept_op(",") is None:
|
||||
break
|
||||
self._expect_op(")")
|
||||
return _call(name, args)
|
||||
|
||||
|
||||
def evaluate_balance(data: object, expression: str) -> float:
|
||||
"""对响应 JSON 求值余额表达式,返回 float。失败抛 ExprError。"""
|
||||
return _Evaluator(data, expression).evaluate()
|
||||
+21
-47
@@ -2,18 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
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}}"
|
||||
_TOKEN_RE = re.compile(r"([^.\[]+)|\[(\d+)\]")
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -33,57 +33,31 @@ def render_template(text: str, api_key: str) -> str:
|
||||
|
||||
|
||||
def extract_balance(data: object, path: str) -> float:
|
||||
"""按点路径/数组索引提取余额,如 data.balance、data[0].balance、$.data[0].balance。
|
||||
"""按点路径/数组索引提取余额(兼容旧写法,返回 float)。
|
||||
|
||||
取到的值必须是数字或可转数字的字符串,否则抛 ValueError。
|
||||
新写法请用 evaluate_balance(支持运算符与函数)。
|
||||
"""
|
||||
p = path.strip()
|
||||
if p.startswith("$"):
|
||||
p = p[1:]
|
||||
cur: object = data
|
||||
for key, idx in _TOKEN_RE.findall(p):
|
||||
if key:
|
||||
if not isinstance(cur, dict) or key not in cur:
|
||||
raise ValueError(f"路径不存在: {path}(在 {key!r} 处)")
|
||||
cur = cur[key]
|
||||
if idx != "":
|
||||
n = int(idx)
|
||||
if not isinstance(cur, list) or n >= len(cur):
|
||||
raise ValueError(f"数组索引越界: {path}(索引 {n})")
|
||||
cur = cur[n]
|
||||
if isinstance(cur, bool) or not isinstance(cur, (int, float, str)):
|
||||
raise ValueError(f"余额不是数字: {path} -> {cur!r}")
|
||||
try:
|
||||
return float(cur)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"余额不是数字: {path} -> {cur!r}")
|
||||
return to_number(extract_path(data, path), path)
|
||||
|
||||
|
||||
def _build_request(platform: dict, api_key: str) -> tuple[str, dict, str | None]:
|
||||
url = render_template(platform["url"], api_key)
|
||||
headers_raw = platform.get("headers") or {}
|
||||
if isinstance(headers_raw, str):
|
||||
headers_raw = json.loads(headers_raw) if headers_raw.strip() else {}
|
||||
headers = json.loads(render_template(json.dumps(headers_raw), api_key))
|
||||
body = None
|
||||
if platform["method"] == "POST" and platform.get("body"):
|
||||
body = render_template(platform["body"], api_key)
|
||||
if not any(k.lower() == "content-type" for k in headers):
|
||||
headers["Content-Type"] = "application/json"
|
||||
return url, headers, body
|
||||
|
||||
|
||||
def fetch_balance(platform: dict, api_key: str, retry_count: int, timeout: int) -> FetchResult:
|
||||
def fetch_balance(
|
||||
provider: BalanceProvider,
|
||||
api_key: str,
|
||||
retry_count: int,
|
||||
timeout: int,
|
||||
) -> FetchResult:
|
||||
"""执行一次余额拉取。
|
||||
|
||||
策略:
|
||||
- 401/403 → auth_error(不重试,由调用方禁用账号并通知)
|
||||
- 其他 4xx → 直接失败(配置问题,不重试)
|
||||
- 401/403 → 先按重试次数确认(可能瞬时),仍失败 → auth_error(调用方禁用账号并通知)
|
||||
- 其他 4xx → 直接失败(不重试)
|
||||
- 网络异常 / 5xx → 重试 retry_count 次,间隔 2s
|
||||
- JSON 解析/路径提取失败 → 直接失败
|
||||
- JSON 解析/余额提取失败 → 直接失败
|
||||
"""
|
||||
url, headers, body = _build_request(platform, api_key)
|
||||
method = platform["method"]
|
||||
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):
|
||||
try:
|
||||
@@ -118,8 +92,8 @@ def fetch_balance(platform: dict, api_key: str, retry_count: int, timeout: int)
|
||||
return FetchResult(ok=False, status_code=resp.status_code,
|
||||
error="响应不是合法 JSON")
|
||||
try:
|
||||
balance = extract_balance(data, platform["balance_path"])
|
||||
except ValueError as exc:
|
||||
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} 次后仍失败")
|
||||
+5
-13
@@ -8,14 +8,10 @@ from pydantic import BaseModel, Field, model_validator
|
||||
# ---------- 平台 ----------
|
||||
|
||||
class PlatformBase(BaseModel):
|
||||
provider_id: str = Field(min_length=1, max_length=64)
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
currency: str = Field(default="USD", min_length=1, max_length=16)
|
||||
icon: str = Field(default="", max_length=64)
|
||||
method: str = Field(default="GET", pattern="^(GET|POST)$")
|
||||
url: str = Field(min_length=1)
|
||||
headers: dict = Field(default_factory=dict)
|
||||
body: str = Field(default="")
|
||||
balance_path: str = Field(min_length=1)
|
||||
currency: str | None = Field(default=None, min_length=1, max_length=16)
|
||||
icon: str | None = Field(default=None, max_length=64)
|
||||
interval_seconds: int | None = Field(default=None, ge=10)
|
||||
retry_count: int | None = Field(default=None, ge=0, le=10)
|
||||
timeout_seconds: int | None = Field(default=None, ge=1, le=120)
|
||||
@@ -28,14 +24,10 @@ class PlatformCreate(PlatformBase):
|
||||
|
||||
|
||||
class PlatformUpdate(BaseModel):
|
||||
provider_id: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
currency: str | None = Field(default=None, min_length=1, max_length=16)
|
||||
currency: str | None = Field(default=None, max_length=16)
|
||||
icon: str | None = Field(default=None, max_length=64)
|
||||
method: str | None = Field(default=None, pattern="^(GET|POST)$")
|
||||
url: str | None = Field(default=None, min_length=1)
|
||||
headers: dict | None = None
|
||||
body: str | None = None
|
||||
balance_path: str | None = Field(default=None, min_length=1)
|
||||
interval_seconds: int | None = Field(default=None, ge=10)
|
||||
retry_count: int | None = Field(default=None, ge=0, le=10)
|
||||
timeout_seconds: int | None = Field(default=None, ge=1, le=120)
|
||||
|
||||
+19
-8
@@ -18,6 +18,7 @@ from app import db
|
||||
from app.alert import evaluate, notify_disabled
|
||||
from app.config import Config
|
||||
from app.fetcher import fetch_balance
|
||||
from app.providers import get_provider
|
||||
|
||||
logger = logging.getLogger("monitor.scheduler")
|
||||
|
||||
@@ -28,17 +29,15 @@ _ACCOUNT_FIELDS = [
|
||||
"last_check_at", "note",
|
||||
]
|
||||
_PLATFORM_FIELDS = [
|
||||
"platform_id", "platform_name", "currency", "icon", "method", "url",
|
||||
"headers", "body", "balance_path", "interval_seconds",
|
||||
"retry_count", "timeout_seconds", "platform_enabled", "note",
|
||||
"platform_id", "provider_id", "platform_name", "currency", "icon",
|
||||
"interval_seconds", "retry_count", "timeout_seconds", "platform_enabled", "note",
|
||||
]
|
||||
|
||||
_ACCOUNT_SQL = """
|
||||
SELECT a.*,
|
||||
p.name AS platform_name, p.currency, p.icon, p.method, p.url,
|
||||
p.headers, p.body, p.balance_path, p.interval_seconds,
|
||||
p.retry_count, p.timeout_seconds, p.enabled AS platform_enabled,
|
||||
p.note AS platform_note
|
||||
p.provider_id, p.name AS platform_name, p.currency, p.icon,
|
||||
p.interval_seconds, p.retry_count, p.timeout_seconds,
|
||||
p.enabled AS platform_enabled, p.note AS platform_note
|
||||
FROM accounts a JOIN platforms p ON p.id = a.platform_id
|
||||
"""
|
||||
|
||||
@@ -138,13 +137,25 @@ class Monitor:
|
||||
def _check_one(self, row: dict) -> None:
|
||||
cfg = self.cfg
|
||||
account, platform = _split(row)
|
||||
provider = get_provider(platform["provider_id"])
|
||||
if provider is None:
|
||||
logger.warning("平台 %s 的 provider 不存在: %r", platform["name"], platform["provider_id"])
|
||||
with db.get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE accounts SET last_status='error', last_error=?, "
|
||||
"last_check_at=datetime('now','localtime') WHERE id=?",
|
||||
(f"provider 不存在: {platform['provider_id']}", account["id"]),
|
||||
)
|
||||
with self._lock:
|
||||
self._next_check[account["id"]] = time.time() + _account_interval(platform, cfg)
|
||||
return
|
||||
retry = platform.get("retry_count")
|
||||
if retry is None:
|
||||
retry = cfg.retry_count
|
||||
timeout = platform.get("timeout_seconds")
|
||||
if timeout is None:
|
||||
timeout = cfg.timeout_seconds
|
||||
result = fetch_balance(platform, account["api_key"], int(retry), int(timeout))
|
||||
result = fetch_balance(provider, account["api_key"], int(retry), int(timeout))
|
||||
|
||||
with db.get_conn() as conn:
|
||||
if result.ok:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""平台适配器注册表:新增平台 = 加一个文件 + 在这里登记。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.providers.base import BalanceProvider
|
||||
from app.providers.deepseek import DeepSeekProvider
|
||||
|
||||
PROVIDERS: dict[str, type[BalanceProvider]] = {
|
||||
"deepseek": DeepSeekProvider,
|
||||
}
|
||||
|
||||
|
||||
def get_provider(provider_id: str) -> BalanceProvider | None:
|
||||
cls = PROVIDERS.get(provider_id)
|
||||
return cls() if cls else None
|
||||
|
||||
|
||||
def list_providers() -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"currency": p.currency,
|
||||
"icon": p.icon,
|
||||
"description": p.description,
|
||||
}
|
||||
for p in (cls() for cls in PROVIDERS.values())
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
"""平台适配器基类。
|
||||
|
||||
多平台支持通过继承 BalanceProvider 实现:
|
||||
1. 新建 app/providers/<name>.py
|
||||
2. 继承 BalanceProvider,实现 build_request / extract_balance
|
||||
3. 在 __init__.py 的 PROVIDERS 注册表中登记
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class BalanceProvider(ABC):
|
||||
"""一个平台的余额获取实现。"""
|
||||
|
||||
#: 唯一标识(存入 platforms.provider_id)
|
||||
id: str = ""
|
||||
#: 平台显示名
|
||||
name: str = ""
|
||||
#: 默认货币单位
|
||||
currency: str = "USD"
|
||||
#: @lobehub/icons 键(前端品牌色映射)
|
||||
icon: str = ""
|
||||
#: 简要说明(前端展示)
|
||||
description: str = ""
|
||||
#: 请求方法
|
||||
method: str = "GET"
|
||||
|
||||
@abstractmethod
|
||||
def build_request(self, api_key: str) -> tuple[str, dict, str | None]:
|
||||
"""根据 api_key 构建请求,返回 (url, headers, body)。
|
||||
|
||||
body 为 None 表示无请求体;POST 平台返回 JSON 字符串。
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def extract_balance(self, data: object) -> float:
|
||||
"""从响应 JSON 中提取余额(数字),失败抛异常由上层记录。"""
|
||||
@@ -0,0 +1,28 @@
|
||||
"""DeepSeek 开放平台余额。
|
||||
|
||||
接口:GET https://api.deepseek.com/user/balance
|
||||
响应:{"is_available": true, "balance_infos": [{"currency": "CNY", "total_balance": "1.34", ...}]}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.expr import evaluate_balance
|
||||
from app.providers.base import BalanceProvider
|
||||
|
||||
|
||||
class DeepSeekProvider(BalanceProvider):
|
||||
id = "deepseek"
|
||||
name = "DeepSeek"
|
||||
currency = "CNY"
|
||||
icon = "DeepSeek"
|
||||
description = "DeepSeek 开放平台余额(GET /user/balance,Bearer 认证)"
|
||||
|
||||
def build_request(self, api_key: str) -> tuple[str, dict, str | None]:
|
||||
return (
|
||||
"https://api.deepseek.com/user/balance",
|
||||
{"Accept": "application/json", "Authorization": f"Bearer {api_key}"},
|
||||
None,
|
||||
)
|
||||
|
||||
def extract_balance(self, data: object) -> float:
|
||||
return evaluate_balance(data, "balance_infos[0].total_balance")
|
||||
+40
-35
@@ -6,6 +6,7 @@
|
||||
let token = localStorage.getItem(TOKEN_KEY) || "";
|
||||
let accounts = [];
|
||||
let platforms = [];
|
||||
let providersList = [];
|
||||
let settings = null;
|
||||
let refreshTimer = null;
|
||||
let filterStatus = "all";
|
||||
@@ -141,10 +142,13 @@
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [acc, plat, hist] = await Promise.all([api("/accounts"), api("/platforms"), api("/history")]);
|
||||
const [acc, plat, hist, provs] = await Promise.all([
|
||||
api("/accounts"), api("/platforms"), api("/history"), api("/providers"),
|
||||
]);
|
||||
accounts = acc;
|
||||
platforms = plat;
|
||||
historyCache = hist || {};
|
||||
providersList = provs || [];
|
||||
renderAccounts();
|
||||
if (!document.getElementById("view-platforms").classList.contains("hidden")) renderPlatforms();
|
||||
const d = new Date();
|
||||
@@ -347,6 +351,7 @@
|
||||
}
|
||||
list.innerHTML = platforms.map((p, i) => {
|
||||
const b = brandStyle(p.icon || "");
|
||||
const prov = providersList.find((x) => x.id === p.provider_id);
|
||||
return `
|
||||
<div class="platform-row ${p.enabled ? "" : "platform-off"}" style="animation-delay:${Math.min(i, 10) * 35}ms">
|
||||
<div class="badge" style="background:${b.color}">${escapeHtml(b.abbr)}</div>
|
||||
@@ -355,11 +360,11 @@
|
||||
${p.enabled ? "" : '<span class="badge-pill disabled">已停用</span>'}
|
||||
</div>
|
||||
<div class="platform-meta">
|
||||
${escapeHtml(p.method)} ${escapeHtml(p.url)} ·
|
||||
提取 ${escapeHtml(p.balance_path)} ·
|
||||
${prov ? escapeHtml(prov.name) : "未知提供方"} ·
|
||||
${escapeHtml(p.currency)} ·
|
||||
间隔 ${p.interval_seconds || "全局"}s ·
|
||||
${p.account_count} 个账号
|
||||
${prov && prov.description ? `<div style="color:var(--text-3)">${escapeHtml(prov.description)}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="platform-actions">
|
||||
@@ -407,35 +412,28 @@
|
||||
function platformForm(p) {
|
||||
const isEdit = !!p;
|
||||
const v = p || {};
|
||||
let savedHeaders = v.headers || {};
|
||||
if (typeof savedHeaders === "string") {
|
||||
try { savedHeaders = JSON.parse(savedHeaders); } catch (_) { savedHeaders = {}; }
|
||||
}
|
||||
const headersStr = Object.keys(savedHeaders).length ? JSON.stringify(savedHeaders, null, 2) : '{\n "Authorization": "Bearer {{apiKey}}"\n}';
|
||||
const provOpts = providersList.length
|
||||
? providersList.map((x) => `<option value="${x.id}" ${v.provider_id === x.id ? "selected" : ""}>${escapeHtml(x.name)}</option>`).join("")
|
||||
: '<option value="">加载中…</option>';
|
||||
const prov = providersList.find((x) => x.id === v.provider_id);
|
||||
openModal(`
|
||||
<div class="modal">
|
||||
<h3>${isEdit ? "编辑平台" : "添加平台"}</h3>
|
||||
<div class="form-grid">
|
||||
<div class="form-row"><label>名称 *</label><input id="pf-name" value="${escapeHtml(v.name || "")}" placeholder="如 OpenAI"></div>
|
||||
<div class="form-row"><label>货币单位 *</label><input id="pf-currency" value="${escapeHtml(v.currency || "USD")}"></div>
|
||||
<div class="form-row"><label>图标键(@lobehub/icons)</label><input id="pf-icon" value="${escapeHtml(v.icon || "")}" placeholder="如 OpenAI / DeepSeek"></div>
|
||||
<div class="form-row">
|
||||
<label>请求方法</label>
|
||||
<select id="pf-method">
|
||||
<option value="GET" ${(v.method || "GET") === "GET" ? "selected" : ""}>GET</option>
|
||||
<option value="POST" ${v.method === "POST" ? "selected" : ""}>POST</option>
|
||||
</select>
|
||||
<div class="form-row full">
|
||||
<label>平台提供方 *(代码内置)</label>
|
||||
<select id="pf-provider">${provOpts}</select>
|
||||
<div class="form-hint" id="pf-prov-desc">${prov ? escapeHtml(prov.description) : ""}</div>
|
||||
</div>
|
||||
<div class="form-row full"><label>URL *(支持 {{apiKey}})</label><input id="pf-url" value="${escapeHtml(v.url || "")}" placeholder="https://api.openai.com/v1/dashboard/billing/credit_grants?api_key={{apiKey}}"></div>
|
||||
<div class="form-row full"><label>Headers(JSON,值支持 {{apiKey}})</label><textarea id="pf-headers">${escapeHtml(headersStr)}</textarea></div>
|
||||
<div class="form-row full"><label>Body(POST 时使用,JSON 模板,支持 {{apiKey}})</label><textarea id="pf-body" placeholder='{"api_key": "{{apiKey}}"}'>${escapeHtml(v.body || "")}</textarea></div>
|
||||
<div class="form-row"><label>余额提取路径 *</label><input id="pf-path" value="${escapeHtml(v.balance_path || "")}" placeholder="data.balance 或 data[0].balance"></div>
|
||||
<div class="form-row"><label>名称 *</label><input id="pf-name" value="${escapeHtml(v.name || "")}"></div>
|
||||
<div class="form-row"><label>货币单位</label><input id="pf-currency" value="${escapeHtml(v.currency || "")}" placeholder="默认来自提供方"></div>
|
||||
<div class="form-row"><label>图标键(@lobehub/icons)</label><input id="pf-icon" value="${escapeHtml(v.icon || "")}" placeholder="默认来自提供方"></div>
|
||||
<div class="form-row"><label>监控间隔(秒,留空用全局)</label><input id="pf-interval" type="number" min="10" value="${v.interval_seconds ?? ""}" placeholder="全局 ${settings ? settings.global_interval_seconds : 300}s"></div>
|
||||
<div class="form-row"><label>重试次数(留空用全局 ${settings ? settings.retry_count : 2})</label><input id="pf-retry" type="number" min="0" max="10" value="${v.retry_count ?? ""}"></div>
|
||||
<div class="form-row"><label>超时秒数(留空用全局 ${settings ? settings.timeout_seconds : 10})</label><input id="pf-timeout" type="number" min="1" max="120" value="${v.timeout_seconds ?? ""}"></div>
|
||||
<div class="form-row full"><label>备注</label><input id="pf-note" value="${escapeHtml(v.note || "")}"></div>
|
||||
</div>
|
||||
<p class="form-hint">提示:apikey 在 URL / Header / Body 中统一用 {{apiKey}} 占位,添加账号时自动替换。Headers/Body 需为 JSON,键值用双引号(单引号会自动兼容)。</p>
|
||||
<p class="form-hint">余额获取方式由代码内置的平台适配器决定,无需配置请求地址与提取路径。</p>
|
||||
<p class="modal-error" id="pf-error"></p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" data-cancel>取消</button>
|
||||
@@ -443,23 +441,30 @@
|
||||
</div>
|
||||
</div>`, (mask) => {
|
||||
mask.querySelector("[data-cancel]").onclick = () => closeModal(mask);
|
||||
const provSel = mask.querySelector("#pf-provider");
|
||||
provSel.onchange = () => {
|
||||
const x = providersList.find((q) => q.id === provSel.value);
|
||||
if (!x) return;
|
||||
mask.querySelector("#pf-prov-desc").textContent = x.description || "";
|
||||
if (!mask.querySelector("#pf-name").value || !isEdit) mask.querySelector("#pf-name").value = x.name;
|
||||
if (!mask.querySelector("#pf-currency").value) mask.querySelector("#pf-currency").value = x.currency;
|
||||
if (!mask.querySelector("#pf-icon").value) mask.querySelector("#pf-icon").value = x.icon;
|
||||
};
|
||||
mask.querySelector("#pf-save").onclick = async () => {
|
||||
const providerId = val("#pf-provider");
|
||||
const payload = {
|
||||
name: val("#pf-name"), currency: val("#pf-currency"), icon: val("#pf-icon"),
|
||||
method: val("#pf-method"), url: val("#pf-url"), balance_path: val("#pf-path"),
|
||||
provider_id: providerId,
|
||||
name: val("#pf-name"),
|
||||
currency: val("#pf-currency") || null,
|
||||
icon: val("#pf-icon") || null,
|
||||
note: val("#pf-note"),
|
||||
interval_seconds: numOrNull("#pf-interval"), retry_count: numOrNull("#pf-retry"),
|
||||
timeout_seconds: numOrNull("#pf-timeout"), enabled: p ? p.enabled : true,
|
||||
interval_seconds: numOrNull("#pf-interval"),
|
||||
retry_count: numOrNull("#pf-retry"),
|
||||
timeout_seconds: numOrNull("#pf-timeout"),
|
||||
enabled: p ? p.enabled : true,
|
||||
};
|
||||
let headers = parseJsonInput(val("#pf-headers"), "Headers", "#pf-error");
|
||||
if (headers === null) return;
|
||||
payload.headers = headers;
|
||||
if (!payload.name || !payload.url || !payload.balance_path) { err("#pf-error", "名称 / URL / 提取路径必填"); return; }
|
||||
if (payload.method === "POST") {
|
||||
const bodyObj = parseJsonInput(val("#pf-body"), "Body", "#pf-error");
|
||||
if (bodyObj === null) return;
|
||||
}
|
||||
payload.body = val("#pf-body");
|
||||
if (!providerId) { err("#pf-error", "请选择平台提供方"); return; }
|
||||
if (!payload.name) { err("#pf-error", "名称必填"); return; }
|
||||
try {
|
||||
if (isEdit) await api("/platforms/" + p.id, { method: "PUT", body: JSON.stringify(payload) });
|
||||
else await api("/platforms", { method: "POST", body: JSON.stringify(payload) });
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ PLATFORM = {"name": "OpenAI", "currency": "USD"}
|
||||
|
||||
def _insert_account(test_db, armed=1, enabled=1):
|
||||
with db.get_conn() as conn:
|
||||
conn.execute("INSERT INTO platforms (id, name, url, balance_path) VALUES (1, 'OpenAI', 'http://x', 'b')")
|
||||
conn.execute("INSERT INTO platforms (id, provider_id, name) VALUES (1, 'deepseek', 'OpenAI')")
|
||||
conn.execute(
|
||||
"""INSERT INTO accounts (id, platform_id, name, api_key, threshold, enabled, alert_armed)
|
||||
VALUES (1, 1, '主账号', 'a2tva2V5', 20.0, ?, ?)""",
|
||||
|
||||
+18
-10
@@ -45,10 +45,7 @@ class TestAuth:
|
||||
|
||||
|
||||
PLATFORM_PAYLOAD = {
|
||||
"name": "OpenAI", "currency": "USD", "icon": "OpenAI", "method": "GET",
|
||||
"url": "https://x.test?key={{apiKey}}",
|
||||
"headers": {"Authorization": "Bearer {{apiKey}}"},
|
||||
"body": "", "balance_path": "data.balance",
|
||||
"provider_id": "deepseek", "name": "DeepSeek-Test",
|
||||
"interval_seconds": 120, "retry_count": 1, "timeout_seconds": 15,
|
||||
"enabled": True, "note": "",
|
||||
}
|
||||
@@ -60,11 +57,13 @@ class TestPlatforms:
|
||||
pid = client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h).json()["id"]
|
||||
|
||||
lst = client.get("/api/platforms", headers=h).json()
|
||||
assert len(lst) == 1 and lst[0]["account_count"] == 0 and lst[0]["url"] == PLATFORM_PAYLOAD["url"]
|
||||
assert len(lst) == 1 and lst[0]["account_count"] == 0
|
||||
assert lst[0]["provider_id"] == "deepseek"
|
||||
assert lst[0]["currency"] == "CNY" # 默认取 provider 的货币
|
||||
|
||||
upd = client.put(f"/api/platforms/{pid}", json={"currency": "CNY", "interval_seconds": 300}, headers=h)
|
||||
upd = client.put(f"/api/platforms/{pid}", json={"currency": "USD", "interval_seconds": 300}, headers=h)
|
||||
assert upd.status_code == 200
|
||||
assert client.get("/api/platforms", headers=h).json()[0]["currency"] == "CNY"
|
||||
assert client.get("/api/platforms", headers=h).json()[0]["currency"] == "USD"
|
||||
|
||||
assert client.delete(f"/api/platforms/{pid}", headers=h).status_code == 200
|
||||
assert client.get("/api/platforms", headers=h).json() == []
|
||||
@@ -74,10 +73,19 @@ class TestPlatforms:
|
||||
client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h)
|
||||
assert client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h).status_code == 409
|
||||
|
||||
def test_unknown_provider_400(self, client):
|
||||
h = _auth(client)
|
||||
assert client.post("/api/platforms", json=dict(PLATFORM_PAYLOAD, provider_id="nope"), headers=h).status_code == 400
|
||||
|
||||
def test_validation_errors(self, client):
|
||||
h = _auth(client)
|
||||
assert client.post("/api/platforms", json=dict(PLATFORM_PAYLOAD, method="DELETE"), headers=h).status_code == 422
|
||||
assert client.post("/api/platforms", json=dict(PLATFORM_PAYLOAD, url=""), headers=h).status_code == 422
|
||||
assert client.post("/api/platforms", json=dict(PLATFORM_PAYLOAD, name=""), headers=h).status_code == 422
|
||||
assert client.post("/api/platforms", json=dict(PLATFORM_PAYLOAD, interval_seconds=5), headers=h).status_code == 422
|
||||
|
||||
def test_providers_list(self, client):
|
||||
h = _auth(client)
|
||||
provs = client.get("/api/providers", headers=h).json()
|
||||
assert any(p["id"] == "deepseek" and p["currency"] == "CNY" for p in provs)
|
||||
|
||||
def test_delete_cascades_accounts(self, client, test_db):
|
||||
from app import db
|
||||
@@ -100,7 +108,7 @@ class TestAccounts:
|
||||
aid = client.post("/api/accounts", json={"platform_id": pid, "name": "主", "api_key": "sk-secret", "threshold": 10}, headers=h).json()["id"]
|
||||
acc = client.get("/api/accounts", headers=h).json()[0]
|
||||
assert acc["id"] == aid and acc["api_key"] == "sk-secret"
|
||||
assert acc["platform_name"] == "OpenAI" and acc["currency"] == "USD"
|
||||
assert acc["platform_name"] == "DeepSeek-Test" and acc["currency"] == "CNY"
|
||||
|
||||
def test_key_stored_base64(self, client, test_db):
|
||||
from app import db
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""余额表达式引擎测试:运算符、函数、兼容性与安全。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.expr import ExprError, evaluate_balance
|
||||
|
||||
DATA = {
|
||||
"data": {
|
||||
"balance": "1.50",
|
||||
"total": 100,
|
||||
"used": 40,
|
||||
"fee": 2.5,
|
||||
},
|
||||
"list": [
|
||||
{"x": 1, "y": 10},
|
||||
{"x": 2, "y": 20},
|
||||
],
|
||||
"balances": [1, 2, 3],
|
||||
"nested": {"a": {"b": 6}},
|
||||
}
|
||||
|
||||
|
||||
class TestPlainPath:
|
||||
def test_dot_path(self):
|
||||
assert evaluate_balance({"data": {"balance": 12.5}}, "data.balance") == 12.5
|
||||
|
||||
def test_array_index(self):
|
||||
assert evaluate_balance(DATA, "list[0].x") == 1
|
||||
|
||||
def test_dollar_prefix(self):
|
||||
assert evaluate_balance(DATA, "$.data.total") == 100
|
||||
|
||||
def test_string_number(self):
|
||||
assert evaluate_balance(DATA, "data.balance") == 1.5
|
||||
|
||||
def test_missing_path(self):
|
||||
with pytest.raises(ExprError, match="路径不存在"):
|
||||
evaluate_balance(DATA, "data.nope")
|
||||
|
||||
def test_index_out_of_range(self):
|
||||
with pytest.raises(ExprError, match="数组索引越界"):
|
||||
evaluate_balance(DATA, "list[5].x")
|
||||
|
||||
|
||||
class TestOperators:
|
||||
def test_division(self):
|
||||
assert evaluate_balance(DATA, "data.total / 100") == 1.0
|
||||
|
||||
def test_addition(self):
|
||||
assert evaluate_balance(DATA, "data.total + data.used") == 140
|
||||
|
||||
def test_priority(self):
|
||||
assert evaluate_balance(DATA, "data.total + data.used * 2") == 180
|
||||
assert evaluate_balance(DATA, "(data.total + data.used) * 2") == 280
|
||||
|
||||
def test_floor_div_and_mod(self):
|
||||
assert evaluate_balance(DATA, "data.total // 30") == 3
|
||||
assert evaluate_balance(DATA, "data.total % 30") == 10
|
||||
|
||||
def test_power(self):
|
||||
assert evaluate_balance(DATA, "2 ** 3 * 5") == 40
|
||||
|
||||
def test_unary_minus(self):
|
||||
assert evaluate_balance(DATA, "-data.total") == -100
|
||||
assert evaluate_balance(DATA, "data.total - -data.used") == 140
|
||||
|
||||
def test_float_result(self):
|
||||
assert evaluate_balance(DATA, "data.total / 8") == 12.5
|
||||
|
||||
|
||||
class TestFunctions:
|
||||
def test_float(self):
|
||||
assert evaluate_balance(DATA, "float(data.balance)") == 1.5
|
||||
|
||||
def test_int(self):
|
||||
assert evaluate_balance(DATA, "int(data.total / 3)") == 33
|
||||
|
||||
def test_abs(self):
|
||||
assert evaluate_balance(DATA, "abs(data.used - data.total)") == 60
|
||||
|
||||
def test_round_one_arg(self):
|
||||
assert evaluate_balance(DATA, "round(data.fee * 3)") == 8
|
||||
|
||||
def test_round_two_args(self):
|
||||
assert evaluate_balance(DATA, "round(data.fee, 1)") == 2.5
|
||||
assert evaluate_balance(DATA, "round(3.14159, 2)") == 3.14
|
||||
|
||||
def test_sum_multi_args(self):
|
||||
assert evaluate_balance(DATA, "sum(data.total, data.used, data.fee)") == 142.5
|
||||
|
||||
def test_sum_array(self):
|
||||
assert evaluate_balance(DATA, "sum(balances)") == 6
|
||||
|
||||
def test_min_max(self):
|
||||
assert evaluate_balance(DATA, "min(data.total, data.used)") == 40
|
||||
assert evaluate_balance(DATA, "max(data.total, data.used)") == 100
|
||||
assert evaluate_balance(DATA, "min(balances)") == 1
|
||||
assert evaluate_balance(DATA, "max(balances)") == 3
|
||||
|
||||
def test_len(self):
|
||||
assert evaluate_balance(DATA, "len(balances)") == 3
|
||||
|
||||
def test_nested_call(self):
|
||||
assert evaluate_balance(DATA, "round(abs(data.used - data.total) / 3, 1)") == 20.0
|
||||
|
||||
|
||||
class TestErrors:
|
||||
def test_unknown_function(self):
|
||||
with pytest.raises(ExprError, match="不支持的函数"):
|
||||
evaluate_balance(DATA, "eval(data.balance)")
|
||||
|
||||
def test_syntax_error(self):
|
||||
with pytest.raises(ExprError):
|
||||
evaluate_balance(DATA, "data.total +")
|
||||
with pytest.raises(ExprError):
|
||||
evaluate_balance(DATA, "(data.total")
|
||||
|
||||
def test_bad_arity(self):
|
||||
with pytest.raises(ExprError, match="float"):
|
||||
evaluate_balance(DATA, "float()")
|
||||
with pytest.raises(ExprError, match="round"):
|
||||
evaluate_balance(DATA, "round(data.total, 2, 3)")
|
||||
|
||||
def test_division_by_zero(self):
|
||||
with pytest.raises((ZeroDivisionError, ExprError)):
|
||||
evaluate_balance(DATA, "data.total / 0")
|
||||
|
||||
def test_non_numeric_result(self):
|
||||
with pytest.raises(ExprError, match="不是数字"):
|
||||
evaluate_balance(DATA, "sum(list)") # 数组元素是对象,无法转数字
|
||||
|
||||
def test_string_literal_rejected(self):
|
||||
with pytest.raises(ExprError):
|
||||
evaluate_balance(DATA, "data.total + 'abc'")
|
||||
+47
-18
@@ -68,13 +68,32 @@ class FakeResponse:
|
||||
return self._json
|
||||
|
||||
|
||||
PLATFORM_GET = {
|
||||
"method": "GET",
|
||||
"url": "https://x.test/api?key={{apiKey}}",
|
||||
"headers": {"Authorization": "Bearer {{apiKey}}"},
|
||||
"body": "",
|
||||
"balance_path": "data.balance",
|
||||
}
|
||||
class FakeProvider:
|
||||
"""测试用 provider:URL 带 key、余额在 data.balance。"""
|
||||
|
||||
method = "GET"
|
||||
|
||||
def __init__(self, path="data.balance"):
|
||||
self.path = path
|
||||
|
||||
def build_request(self, api_key):
|
||||
return (
|
||||
"https://x.test/api?key=" + api_key,
|
||||
{"Authorization": "Bearer " + api_key},
|
||||
None,
|
||||
)
|
||||
|
||||
def extract_balance(self, data):
|
||||
from app.expr import evaluate_balance
|
||||
|
||||
return evaluate_balance(data, self.path)
|
||||
|
||||
|
||||
class FakePostProvider(FakeProvider):
|
||||
method = "POST"
|
||||
|
||||
def build_request(self, api_key):
|
||||
return ("https://x.test/api", {}, '{"api_key": "' + api_key + '"}')
|
||||
|
||||
|
||||
class TestFetchBalance:
|
||||
@@ -88,12 +107,11 @@ class TestFetchBalance:
|
||||
return FakeResponse(200, json_data={"data": {"balance": 42.5}})
|
||||
|
||||
monkeypatch.setattr("requests.get", fake_get)
|
||||
result = fetch_balance(PLATFORM_GET, "sk-1", retry_count=2, timeout=10)
|
||||
result = fetch_balance(FakeProvider(), "sk-1", retry_count=2, timeout=10)
|
||||
assert result.ok and result.balance == 42.5
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_post_with_body(self, monkeypatch):
|
||||
platform = dict(PLATFORM_GET, method="POST", body='{"api_key": "{{apiKey}}"}')
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, headers=None, data=None, timeout=10):
|
||||
@@ -101,10 +119,21 @@ class TestFetchBalance:
|
||||
return FakeResponse(200, json_data={"data": {"balance": 1}})
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
result = fetch_balance(platform, "sk-2", retry_count=2, timeout=10)
|
||||
result = fetch_balance(FakePostProvider(), "sk-2", retry_count=2, timeout=10)
|
||||
assert result.ok
|
||||
assert captured["data"] == '{"api_key": "sk-2"}'
|
||||
|
||||
def test_post_content_type_auto(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, headers=None, data=None, timeout=10):
|
||||
captured["headers"] = headers
|
||||
return FakeResponse(200, json_data={"data": {"balance": 1}})
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
fetch_balance(FakePostProvider(), "sk-2", retry_count=0, timeout=10)
|
||||
assert captured["headers"].get("Content-Type") == "application/json"
|
||||
|
||||
def test_401_retries_then_auth_error(self, monkeypatch):
|
||||
calls = []
|
||||
|
||||
@@ -113,7 +142,7 @@ class TestFetchBalance:
|
||||
return FakeResponse(401, text="unauthorized")
|
||||
|
||||
monkeypatch.setattr("requests.get", fake_get)
|
||||
result = fetch_balance(PLATFORM_GET, "bad", retry_count=2, timeout=10)
|
||||
result = fetch_balance(FakeProvider(), "bad", retry_count=2, timeout=10)
|
||||
assert not result.ok and result.auth_error
|
||||
assert len(calls) == 3 # 401 也按重试次数确认后再判定
|
||||
|
||||
@@ -125,7 +154,7 @@ class TestFetchBalance:
|
||||
return FakeResponse(401) if len(calls) == 1 else FakeResponse(200, json_data={"data": {"balance": 6.6}})
|
||||
|
||||
monkeypatch.setattr("requests.get", fake_get)
|
||||
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10)
|
||||
result = fetch_balance(FakeProvider(), "k", retry_count=2, timeout=10)
|
||||
assert result.ok and result.balance == 6.6
|
||||
assert len(calls) == 2 # 瞬时 401 重试后成功,不误判禁用
|
||||
|
||||
@@ -137,13 +166,13 @@ class TestFetchBalance:
|
||||
return FakeResponse(500) if len(calls) < 3 else FakeResponse(200, json_data={"data": {"balance": 5}})
|
||||
|
||||
monkeypatch.setattr("requests.get", fake_get)
|
||||
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10)
|
||||
result = fetch_balance(FakeProvider(), "k", retry_count=2, timeout=10)
|
||||
assert result.ok and result.balance == 5
|
||||
assert len(calls) == 3
|
||||
|
||||
def test_5xx_all_fail(self, monkeypatch):
|
||||
monkeypatch.setattr("requests.get", lambda *a, **k: FakeResponse(503))
|
||||
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10)
|
||||
result = fetch_balance(FakeProvider(), "k", retry_count=2, timeout=10)
|
||||
assert not result.ok and not result.auth_error
|
||||
|
||||
def test_network_error_retries(self, monkeypatch):
|
||||
@@ -157,7 +186,7 @@ class TestFetchBalance:
|
||||
return FakeResponse(200, json_data={"data": {"balance": 8}})
|
||||
|
||||
monkeypatch.setattr("requests.get", fake_get)
|
||||
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10)
|
||||
result = fetch_balance(FakeProvider(), "k", retry_count=2, timeout=10)
|
||||
assert result.ok and result.balance == 8
|
||||
|
||||
def test_other_4xx_no_retry(self, monkeypatch):
|
||||
@@ -168,19 +197,19 @@ class TestFetchBalance:
|
||||
return FakeResponse(404, text="not found")
|
||||
|
||||
monkeypatch.setattr("requests.get", fake_get)
|
||||
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10)
|
||||
result = fetch_balance(FakeProvider(), "k", retry_count=2, timeout=10)
|
||||
assert not result.ok and not result.auth_error
|
||||
assert "404" in result.error
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_bad_json_path(self, monkeypatch):
|
||||
monkeypatch.setattr("requests.get", lambda *a, **k: FakeResponse(200, json_data={"x": 1}))
|
||||
result = fetch_balance(PLATFORM_GET, "k", retry_count=0, timeout=10)
|
||||
result = fetch_balance(FakeProvider(), "k", retry_count=0, timeout=10)
|
||||
assert not result.ok
|
||||
assert "路径不存在" in result.error
|
||||
|
||||
def test_invalid_json_body(self, monkeypatch):
|
||||
monkeypatch.setattr("requests.get", lambda *a, **k: FakeResponse(200, text="<html>"))
|
||||
result = fetch_balance(PLATFORM_GET, "k", retry_count=0, timeout=10)
|
||||
result = fetch_balance(FakeProvider(), "k", retry_count=0, timeout=10)
|
||||
assert not result.ok
|
||||
assert "JSON" in result.error
|
||||
@@ -12,8 +12,7 @@ def _row(**overrides):
|
||||
"threshold": 0, "enabled": 1, "alert_armed": 1,
|
||||
"last_balance": None, "last_status": "pending", "last_error": "",
|
||||
"last_check_at": None, "note": "",
|
||||
"platform_name": "P", "currency": "USD", "icon": "", "method": "GET",
|
||||
"url": "http://x", "headers": "{}", "body": "", "balance_path": "b",
|
||||
"provider_id": "deepseek", "platform_name": "P", "currency": "USD", "icon": "",
|
||||
"interval_seconds": None, "retry_count": None, "timeout_seconds": None,
|
||||
"platform_enabled": 1, "platform_note": "",
|
||||
}
|
||||
@@ -32,8 +31,8 @@ class TestSplit:
|
||||
assert platform["name"] == "P"
|
||||
assert platform["id"] == 2
|
||||
assert platform["enabled"] == 1
|
||||
assert platform["url"] == "http://x"
|
||||
assert platform["balance_path"] == "b"
|
||||
assert platform["provider_id"] == "deepseek"
|
||||
assert platform["currency"] == "USD"
|
||||
|
||||
def test_plain_key_passthrough(self):
|
||||
"""未编码的 key(历史数据)原样使用,不抛错。"""
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""内置平台适配器测试。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.expr import ExprError
|
||||
from app.providers import get_provider, list_providers
|
||||
from app.providers.deepseek import DeepSeekProvider
|
||||
|
||||
DEEPSEEK_RESPONSE = {
|
||||
"is_available": True,
|
||||
"balance_infos": [
|
||||
{
|
||||
"currency": "CNY",
|
||||
"total_balance": "1.34",
|
||||
"granted_balance": "0.00",
|
||||
"topped_up_balance": "1.34",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TestDeepSeek:
|
||||
def test_build_request(self):
|
||||
prov = DeepSeekProvider()
|
||||
url, headers, body = prov.build_request("sk-abc")
|
||||
assert url == "https://api.deepseek.com/user/balance"
|
||||
assert headers["Authorization"] == "Bearer sk-abc"
|
||||
assert body is None
|
||||
assert prov.method == "GET"
|
||||
|
||||
def test_extract_balance(self):
|
||||
assert DeepSeekProvider().extract_balance(DEEPSEEK_RESPONSE) == 1.34
|
||||
|
||||
def test_extract_missing(self):
|
||||
with pytest.raises(ExprError):
|
||||
DeepSeekProvider().extract_balance({"is_available": False})
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
def test_get_provider(self):
|
||||
prov = get_provider("deepseek")
|
||||
assert isinstance(prov, DeepSeekProvider)
|
||||
|
||||
def test_unknown_provider_none(self):
|
||||
assert get_provider("nope") is None
|
||||
|
||||
def test_list_providers(self):
|
||||
provs = list_providers()
|
||||
ids = [p["id"] for p in provs]
|
||||
assert "deepseek" in ids
|
||||
dp = next(p for p in provs if p["id"] == "deepseek")
|
||||
assert dp["name"] == "DeepSeek" and dp["currency"] == "CNY" and dp["icon"] == "DeepSeek"
|
||||
Reference in New Issue
Block a user