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:
2026-08-05 00:56:22 +08:00
parent 1475821519
commit bdeb353bc2
16 changed files with 704 additions and 169 deletions
+32 -28
View File
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import json
import logging import logging
import secrets import secrets
import sqlite3 import sqlite3
@@ -27,6 +26,7 @@ from app.models import (
SettingsUpdate, SettingsUpdate,
) )
from app.monitor import Monitor from app.monitor import Monitor
from app.providers import get_provider, list_providers as list_providers_svc
logger = logging.getLogger("monitor.api") 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)]) @app.get("/api/platforms", dependencies=[Depends(require_auth)])
def list_platforms(): def list_platforms():
with db.get_conn() as conn: with db.get_conn() as conn:
rows = conn.execute( 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""" FROM platforms p ORDER BY p.id"""
).fetchall() ).fetchall()
result = [] return [dict(r) for r in rows]
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
@app.post("/api/platforms", dependencies=[Depends(require_auth)]) @app.post("/api/platforms", dependencies=[Depends(require_auth)])
def create_platform(body: PlatformCreate): 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: try:
with db.get_conn() as conn: with db.get_conn() as conn:
cur = conn.execute( cur = conn.execute(
"""INSERT INTO platforms (name, currency, icon, method, url, headers, body, """INSERT INTO platforms (provider_id, name, currency, icon,
balance_path, interval_seconds, retry_count, timeout_seconds, enabled, note) interval_seconds, retry_count, timeout_seconds, enabled, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
( (
body.name, body.currency, body.icon, body.method, body.url, body.provider_id, body.name, currency, icon,
__import__("json").dumps(body.headers, ensure_ascii=False), body.interval_seconds, body.retry_count, body.timeout_seconds,
body.body, body.balance_path, body.interval_seconds, int(body.enabled), body.note,
body.retry_count, body.timeout_seconds, int(body.enabled), body.note,
), ),
) )
pid = cur.lastrowid pid = cur.lastrowid
@@ -172,22 +176,22 @@ def create_app(cfg: Config | None = None) -> FastAPI:
@app.put("/api/platforms/{pid}", dependencies=[Depends(require_auth)]) @app.put("/api/platforms/{pid}", dependencies=[Depends(require_auth)])
def update_platform(pid: int, body: PlatformUpdate): def update_platform(pid: int, body: PlatformUpdate):
fields = {} 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: if body.name is not None:
fields["name"] = body.name fields["name"] = body.name
if body.currency is not None: if body.currency is not None:
fields["currency"] = body.currency fields["currency"] = body.currency
if body.icon is not None: if body.icon is not None:
fields["icon"] = body.icon 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: if body.interval_seconds is not None:
fields["interval_seconds"] = body.interval_seconds fields["interval_seconds"] = body.interval_seconds
if body.retry_count is not None: if body.retry_count is not None:
+1 -5
View File
@@ -11,14 +11,10 @@ DB_PATH = DATA_DIR / "monitor.db"
SCHEMA = """ SCHEMA = """
CREATE TABLE IF NOT EXISTS platforms ( CREATE TABLE IF NOT EXISTS platforms (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
currency TEXT NOT NULL DEFAULT 'USD', currency TEXT NOT NULL DEFAULT 'USD',
icon TEXT NOT NULL DEFAULT '', 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, interval_seconds INTEGER,
retry_count INTEGER, retry_count INTEGER,
timeout_seconds INTEGER, timeout_seconds INTEGER,
+236
View File
@@ -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
View File
@@ -2,18 +2,18 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import re
import time import time
from dataclasses import dataclass from dataclasses import dataclass
import requests import requests
from app.expr import extract_path, to_number
from app.providers.base import BalanceProvider
logger = logging.getLogger("monitor.fetcher") logger = logging.getLogger("monitor.fetcher")
PLACEHOLDER = "{{apiKey}}" PLACEHOLDER = "{{apiKey}}"
_TOKEN_RE = re.compile(r"([^.\[]+)|\[(\d+)\]")
@dataclass @dataclass
@@ -33,57 +33,31 @@ def render_template(text: str, api_key: str) -> str:
def extract_balance(data: object, path: str) -> float: def extract_balance(data: object, path: str) -> float:
"""按点路径/数组索引提取余额,如 data.balance、data[0].balance、$.data[0].balance """按点路径/数组索引提取余额(兼容旧写法,返回 float
取到的值必须是数字或可转数字的字符串,否则抛 ValueError 新写法请用 evaluate_balance(支持运算符与函数)
""" """
p = path.strip() return to_number(extract_path(data, path), path)
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}")
def _build_request(platform: dict, api_key: str) -> tuple[str, dict, str | None]: def fetch_balance(
url = render_template(platform["url"], api_key) provider: BalanceProvider,
headers_raw = platform.get("headers") or {} api_key: str,
if isinstance(headers_raw, str): retry_count: int,
headers_raw = json.loads(headers_raw) if headers_raw.strip() else {} timeout: int,
headers = json.loads(render_template(json.dumps(headers_raw), api_key)) ) -> FetchResult:
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:
"""执行一次余额拉取。 """执行一次余额拉取。
策略: 策略:
- 401/403 → auth_error不重试,由调用方禁用账号并通知) - 401/403 → 先按重试次数确认(可能瞬时),仍失败 → auth_error(调用方禁用账号并通知)
- 其他 4xx → 直接失败(配置问题,不重试) - 其他 4xx → 直接失败(不重试)
- 网络异常 / 5xx → 重试 retry_count 次,间隔 2s - 网络异常 / 5xx → 重试 retry_count 次,间隔 2s
- JSON 解析/路径提取失败 → 直接失败 - JSON 解析/余额提取失败 → 直接失败
""" """
url, headers, body = _build_request(platform, api_key) url, headers, body = provider.build_request(api_key)
method = platform["method"] 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 attempts = retry_count + 1
for attempt in range(attempts): for attempt in range(attempts):
try: 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, return FetchResult(ok=False, status_code=resp.status_code,
error="响应不是合法 JSON") error="响应不是合法 JSON")
try: try:
balance = extract_balance(data, platform["balance_path"]) balance = provider.extract_balance(data)
except ValueError as exc: except Exception as exc:
return FetchResult(ok=False, status_code=resp.status_code, error=str(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=True, balance=balance, status_code=resp.status_code)
return FetchResult(ok=False, error=f"网络错误,重试 {retry_count} 次后仍失败") return FetchResult(ok=False, error=f"网络错误,重试 {retry_count} 次后仍失败")
+5 -13
View File
@@ -8,14 +8,10 @@ from pydantic import BaseModel, Field, model_validator
# ---------- 平台 ---------- # ---------- 平台 ----------
class PlatformBase(BaseModel): class PlatformBase(BaseModel):
provider_id: str = Field(min_length=1, max_length=64)
name: 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) currency: str | None = Field(default=None, min_length=1, max_length=16)
icon: str = Field(default="", max_length=64) icon: str | None = Field(default=None, 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)
interval_seconds: int | None = Field(default=None, ge=10) interval_seconds: int | None = Field(default=None, ge=10)
retry_count: int | None = Field(default=None, ge=0, le=10) retry_count: int | None = Field(default=None, ge=0, le=10)
timeout_seconds: int | None = Field(default=None, ge=1, le=120) timeout_seconds: int | None = Field(default=None, ge=1, le=120)
@@ -28,14 +24,10 @@ class PlatformCreate(PlatformBase):
class PlatformUpdate(BaseModel): 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) 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) 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) interval_seconds: int | None = Field(default=None, ge=10)
retry_count: int | None = Field(default=None, ge=0, le=10) retry_count: int | None = Field(default=None, ge=0, le=10)
timeout_seconds: int | None = Field(default=None, ge=1, le=120) timeout_seconds: int | None = Field(default=None, ge=1, le=120)
+19 -8
View File
@@ -18,6 +18,7 @@ from app import db
from app.alert import evaluate, notify_disabled from app.alert import evaluate, notify_disabled
from app.config import Config from app.config import Config
from app.fetcher import fetch_balance from app.fetcher import fetch_balance
from app.providers import get_provider
logger = logging.getLogger("monitor.scheduler") logger = logging.getLogger("monitor.scheduler")
@@ -28,17 +29,15 @@ _ACCOUNT_FIELDS = [
"last_check_at", "note", "last_check_at", "note",
] ]
_PLATFORM_FIELDS = [ _PLATFORM_FIELDS = [
"platform_id", "platform_name", "currency", "icon", "method", "url", "platform_id", "provider_id", "platform_name", "currency", "icon",
"headers", "body", "balance_path", "interval_seconds", "interval_seconds", "retry_count", "timeout_seconds", "platform_enabled", "note",
"retry_count", "timeout_seconds", "platform_enabled", "note",
] ]
_ACCOUNT_SQL = """ _ACCOUNT_SQL = """
SELECT a.*, SELECT a.*,
p.name AS platform_name, p.currency, p.icon, p.method, p.url, p.provider_id, p.name AS platform_name, p.currency, p.icon,
p.headers, p.body, p.balance_path, p.interval_seconds, p.interval_seconds, p.retry_count, p.timeout_seconds,
p.retry_count, p.timeout_seconds, p.enabled AS platform_enabled, p.enabled AS platform_enabled, p.note AS platform_note
p.note AS platform_note
FROM accounts a JOIN platforms p ON p.id = a.platform_id 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: def _check_one(self, row: dict) -> None:
cfg = self.cfg cfg = self.cfg
account, platform = _split(row) 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") retry = platform.get("retry_count")
if retry is None: if retry is None:
retry = cfg.retry_count retry = cfg.retry_count
timeout = platform.get("timeout_seconds") timeout = platform.get("timeout_seconds")
if timeout is None: if timeout is None:
timeout = cfg.timeout_seconds 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: with db.get_conn() as conn:
if result.ok: if result.ok:
+28
View File
@@ -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())
]
+39
View File
@@ -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 中提取余额(数字),失败抛异常由上层记录。"""
+28
View File
@@ -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/balanceBearer 认证)"
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
View File
@@ -6,6 +6,7 @@
let token = localStorage.getItem(TOKEN_KEY) || ""; let token = localStorage.getItem(TOKEN_KEY) || "";
let accounts = []; let accounts = [];
let platforms = []; let platforms = [];
let providersList = [];
let settings = null; let settings = null;
let refreshTimer = null; let refreshTimer = null;
let filterStatus = "all"; let filterStatus = "all";
@@ -141,10 +142,13 @@
async function refresh() { async function refresh() {
try { 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; accounts = acc;
platforms = plat; platforms = plat;
historyCache = hist || {}; historyCache = hist || {};
providersList = provs || [];
renderAccounts(); renderAccounts();
if (!document.getElementById("view-platforms").classList.contains("hidden")) renderPlatforms(); if (!document.getElementById("view-platforms").classList.contains("hidden")) renderPlatforms();
const d = new Date(); const d = new Date();
@@ -347,6 +351,7 @@
} }
list.innerHTML = platforms.map((p, i) => { list.innerHTML = platforms.map((p, i) => {
const b = brandStyle(p.icon || ""); const b = brandStyle(p.icon || "");
const prov = providersList.find((x) => x.id === p.provider_id);
return ` return `
<div class="platform-row ${p.enabled ? "" : "platform-off"}" style="animation-delay:${Math.min(i, 10) * 35}ms"> <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> <div class="badge" style="background:${b.color}">${escapeHtml(b.abbr)}</div>
@@ -355,11 +360,11 @@
${p.enabled ? "" : '<span class="badge-pill disabled">已停用</span>'} ${p.enabled ? "" : '<span class="badge-pill disabled">已停用</span>'}
</div> </div>
<div class="platform-meta"> <div class="platform-meta">
${escapeHtml(p.method)} ${escapeHtml(p.url)} · ${prov ? escapeHtml(prov.name) : "未知提供方"} ·
提取 ${escapeHtml(p.balance_path)} ·
${escapeHtml(p.currency)} · ${escapeHtml(p.currency)} ·
间隔 ${p.interval_seconds || "全局"}s · 间隔 ${p.interval_seconds || "全局"}s ·
${p.account_count} 个账号 ${p.account_count} 个账号
${prov && prov.description ? `<div style="color:var(--text-3)">${escapeHtml(prov.description)}</div>` : ""}
</div> </div>
</div> </div>
<div class="platform-actions"> <div class="platform-actions">
@@ -407,35 +412,28 @@
function platformForm(p) { function platformForm(p) {
const isEdit = !!p; const isEdit = !!p;
const v = p || {}; const v = p || {};
let savedHeaders = v.headers || {}; const provOpts = providersList.length
if (typeof savedHeaders === "string") { ? providersList.map((x) => `<option value="${x.id}" ${v.provider_id === x.id ? "selected" : ""}>${escapeHtml(x.name)}</option>`).join("")
try { savedHeaders = JSON.parse(savedHeaders); } catch (_) { savedHeaders = {}; } : '<option value="">加载中…</option>';
} const prov = providersList.find((x) => x.id === v.provider_id);
const headersStr = Object.keys(savedHeaders).length ? JSON.stringify(savedHeaders, null, 2) : '{\n "Authorization": "Bearer {{apiKey}}"\n}';
openModal(` openModal(`
<div class="modal"> <div class="modal">
<h3>${isEdit ? "编辑平台" : "添加平台"}</h3> <h3>${isEdit ? "编辑平台" : "添加平台"}</h3>
<div class="form-grid"> <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 full">
<div class="form-row"><label>货币单位 *</label><input id="pf-currency" value="${escapeHtml(v.currency || "USD")}"></div> <label>平台提供方 *(代码内置)</label>
<div class="form-row"><label>图标键(@lobehub/icons</label><input id="pf-icon" value="${escapeHtml(v.icon || "")}" placeholder="如 OpenAI / DeepSeek"></div> <select id="pf-provider">${provOpts}</select>
<div class="form-row"> <div class="form-hint" id="pf-prov-desc">${prov ? escapeHtml(prov.description) : ""}</div>
<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> </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"><label>名称 *</label><input id="pf-name" value="${escapeHtml(v.name || "")}"></div>
<div class="form-row full"><label>HeadersJSON,值支持 {{apiKey}}</label><textarea id="pf-headers">${escapeHtml(headersStr)}</textarea></div> <div class="form-row"><label>货币单位</label><input id="pf-currency" value="${escapeHtml(v.currency || "")}" placeholder="默认来自提供方"></div>
<div class="form-row full"><label>BodyPOST 时使用,JSON 模板,支持 {{apiKey}}</label><textarea id="pf-body" placeholder='{"api_key": "{{apiKey}}"}'>${escapeHtml(v.body || "")}</textarea></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-path" value="${escapeHtml(v.balance_path || "")}" placeholder="data.balance 或 data[0].balance"></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>监控间隔(秒,留空用全局)</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.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"><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 class="form-row full"><label>备注</label><input id="pf-note" value="${escapeHtml(v.note || "")}"></div>
</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> <p class="modal-error" id="pf-error"></p>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn" data-cancel>取消</button> <button class="btn" data-cancel>取消</button>
@@ -443,23 +441,30 @@
</div> </div>
</div>`, (mask) => { </div>`, (mask) => {
mask.querySelector("[data-cancel]").onclick = () => closeModal(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 () => { mask.querySelector("#pf-save").onclick = async () => {
const providerId = val("#pf-provider");
const payload = { const payload = {
name: val("#pf-name"), currency: val("#pf-currency"), icon: val("#pf-icon"), provider_id: providerId,
method: val("#pf-method"), url: val("#pf-url"), balance_path: val("#pf-path"), name: val("#pf-name"),
currency: val("#pf-currency") || null,
icon: val("#pf-icon") || null,
note: val("#pf-note"), note: val("#pf-note"),
interval_seconds: numOrNull("#pf-interval"), retry_count: numOrNull("#pf-retry"), interval_seconds: numOrNull("#pf-interval"),
timeout_seconds: numOrNull("#pf-timeout"), enabled: p ? p.enabled : true, 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 (!providerId) { err("#pf-error", "请选择平台提供方"); return; }
if (headers === null) return; if (!payload.name) { err("#pf-error", "名称必填"); 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");
try { try {
if (isEdit) await api("/platforms/" + p.id, { method: "PUT", body: JSON.stringify(payload) }); if (isEdit) await api("/platforms/" + p.id, { method: "PUT", body: JSON.stringify(payload) });
else await api("/platforms", { method: "POST", body: JSON.stringify(payload) }); else await api("/platforms", { method: "POST", body: JSON.stringify(payload) });
+1 -1
View File
@@ -14,7 +14,7 @@ PLATFORM = {"name": "OpenAI", "currency": "USD"}
def _insert_account(test_db, armed=1, enabled=1): def _insert_account(test_db, armed=1, enabled=1):
with db.get_conn() as conn: 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( conn.execute(
"""INSERT INTO accounts (id, platform_id, name, api_key, threshold, enabled, alert_armed) """INSERT INTO accounts (id, platform_id, name, api_key, threshold, enabled, alert_armed)
VALUES (1, 1, '主账号', 'a2tva2V5', 20.0, ?, ?)""", VALUES (1, 1, '主账号', 'a2tva2V5', 20.0, ?, ?)""",
+18 -10
View File
@@ -45,10 +45,7 @@ class TestAuth:
PLATFORM_PAYLOAD = { PLATFORM_PAYLOAD = {
"name": "OpenAI", "currency": "USD", "icon": "OpenAI", "method": "GET", "provider_id": "deepseek", "name": "DeepSeek-Test",
"url": "https://x.test?key={{apiKey}}",
"headers": {"Authorization": "Bearer {{apiKey}}"},
"body": "", "balance_path": "data.balance",
"interval_seconds": 120, "retry_count": 1, "timeout_seconds": 15, "interval_seconds": 120, "retry_count": 1, "timeout_seconds": 15,
"enabled": True, "note": "", "enabled": True, "note": "",
} }
@@ -60,11 +57,13 @@ class TestPlatforms:
pid = client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h).json()["id"] pid = client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h).json()["id"]
lst = client.get("/api/platforms", headers=h).json() 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 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.delete(f"/api/platforms/{pid}", headers=h).status_code == 200
assert client.get("/api/platforms", headers=h).json() == [] assert client.get("/api/platforms", headers=h).json() == []
@@ -74,10 +73,19 @@ class TestPlatforms:
client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h) client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h)
assert client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h).status_code == 409 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): def test_validation_errors(self, client):
h = _auth(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, name=""), 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, 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): def test_delete_cascades_accounts(self, client, test_db):
from app import 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"] 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] acc = client.get("/api/accounts", headers=h).json()[0]
assert acc["id"] == aid and acc["api_key"] == "sk-secret" 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): def test_key_stored_base64(self, client, test_db):
from app import db from app import db
+134
View File
@@ -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
View File
@@ -68,13 +68,32 @@ class FakeResponse:
return self._json return self._json
PLATFORM_GET = { class FakeProvider:
"method": "GET", """测试用 providerURL 带 key、余额在 data.balance。"""
"url": "https://x.test/api?key={{apiKey}}",
"headers": {"Authorization": "Bearer {{apiKey}}"}, method = "GET"
"body": "",
"balance_path": "data.balance", 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: class TestFetchBalance:
@@ -88,12 +107,11 @@ class TestFetchBalance:
return FakeResponse(200, json_data={"data": {"balance": 42.5}}) return FakeResponse(200, json_data={"data": {"balance": 42.5}})
monkeypatch.setattr("requests.get", fake_get) 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 result.ok and result.balance == 42.5
assert len(calls) == 1 assert len(calls) == 1
def test_post_with_body(self, monkeypatch): def test_post_with_body(self, monkeypatch):
platform = dict(PLATFORM_GET, method="POST", body='{"api_key": "{{apiKey}}"}')
captured = {} captured = {}
def fake_post(url, headers=None, data=None, timeout=10): def fake_post(url, headers=None, data=None, timeout=10):
@@ -101,10 +119,21 @@ class TestFetchBalance:
return FakeResponse(200, json_data={"data": {"balance": 1}}) return FakeResponse(200, json_data={"data": {"balance": 1}})
monkeypatch.setattr("requests.post", fake_post) 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 result.ok
assert captured["data"] == '{"api_key": "sk-2"}' 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): def test_401_retries_then_auth_error(self, monkeypatch):
calls = [] calls = []
@@ -113,7 +142,7 @@ class TestFetchBalance:
return FakeResponse(401, text="unauthorized") return FakeResponse(401, text="unauthorized")
monkeypatch.setattr("requests.get", fake_get) 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 not result.ok and result.auth_error
assert len(calls) == 3 # 401 也按重试次数确认后再判定 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}}) return FakeResponse(401) if len(calls) == 1 else FakeResponse(200, json_data={"data": {"balance": 6.6}})
monkeypatch.setattr("requests.get", fake_get) 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 result.ok and result.balance == 6.6
assert len(calls) == 2 # 瞬时 401 重试后成功,不误判禁用 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}}) return FakeResponse(500) if len(calls) < 3 else FakeResponse(200, json_data={"data": {"balance": 5}})
monkeypatch.setattr("requests.get", fake_get) 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 result.ok and result.balance == 5
assert len(calls) == 3 assert len(calls) == 3
def test_5xx_all_fail(self, monkeypatch): def test_5xx_all_fail(self, monkeypatch):
monkeypatch.setattr("requests.get", lambda *a, **k: FakeResponse(503)) 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 assert not result.ok and not result.auth_error
def test_network_error_retries(self, monkeypatch): def test_network_error_retries(self, monkeypatch):
@@ -157,7 +186,7 @@ class TestFetchBalance:
return FakeResponse(200, json_data={"data": {"balance": 8}}) return FakeResponse(200, json_data={"data": {"balance": 8}})
monkeypatch.setattr("requests.get", fake_get) 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 assert result.ok and result.balance == 8
def test_other_4xx_no_retry(self, monkeypatch): def test_other_4xx_no_retry(self, monkeypatch):
@@ -168,19 +197,19 @@ class TestFetchBalance:
return FakeResponse(404, text="not found") return FakeResponse(404, text="not found")
monkeypatch.setattr("requests.get", fake_get) 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 not result.ok and not result.auth_error
assert "404" in result.error assert "404" in result.error
assert len(calls) == 1 assert len(calls) == 1
def test_bad_json_path(self, monkeypatch): def test_bad_json_path(self, monkeypatch):
monkeypatch.setattr("requests.get", lambda *a, **k: FakeResponse(200, json_data={"x": 1})) 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 not result.ok
assert "路径不存在" in result.error assert "路径不存在" in result.error
def test_invalid_json_body(self, monkeypatch): def test_invalid_json_body(self, monkeypatch):
monkeypatch.setattr("requests.get", lambda *a, **k: FakeResponse(200, text="<html>")) 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 not result.ok
assert "JSON" in result.error assert "JSON" in result.error
+3 -4
View File
@@ -12,8 +12,7 @@ def _row(**overrides):
"threshold": 0, "enabled": 1, "alert_armed": 1, "threshold": 0, "enabled": 1, "alert_armed": 1,
"last_balance": None, "last_status": "pending", "last_error": "", "last_balance": None, "last_status": "pending", "last_error": "",
"last_check_at": None, "note": "", "last_check_at": None, "note": "",
"platform_name": "P", "currency": "USD", "icon": "", "method": "GET", "provider_id": "deepseek", "platform_name": "P", "currency": "USD", "icon": "",
"url": "http://x", "headers": "{}", "body": "", "balance_path": "b",
"interval_seconds": None, "retry_count": None, "timeout_seconds": None, "interval_seconds": None, "retry_count": None, "timeout_seconds": None,
"platform_enabled": 1, "platform_note": "", "platform_enabled": 1, "platform_note": "",
} }
@@ -32,8 +31,8 @@ class TestSplit:
assert platform["name"] == "P" assert platform["name"] == "P"
assert platform["id"] == 2 assert platform["id"] == 2
assert platform["enabled"] == 1 assert platform["enabled"] == 1
assert platform["url"] == "http://x" assert platform["provider_id"] == "deepseek"
assert platform["balance_path"] == "b" assert platform["currency"] == "USD"
def test_plain_key_passthrough(self): def test_plain_key_passthrough(self):
"""未编码的 key(历史数据)原样使用,不抛错。""" """未编码的 key(历史数据)原样使用,不抛错。"""
+52
View File
@@ -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"